diff options
Diffstat (limited to 'gerber')
-rwxr-xr-x | gerber/excellon.py | 45 | ||||
-rw-r--r-- | gerber/excellon_statements.py | 66 | ||||
-rw-r--r-- | gerber/excellon_tool.py | 70 | ||||
-rw-r--r-- | gerber/gerber_statements.py | 24 | ||||
-rw-r--r-- | gerber/primitives.py | 232 | ||||
-rw-r--r-- | gerber/render/cairo_backend.py | 488 | ||||
-rw-r--r-- | gerber/render/rs274x_backend.py | 51 | ||||
-rw-r--r-- | gerber/rs274x.py | 141 | ||||
-rw-r--r-- | gerber/tests/test_excellon.py | 154 | ||||
-rw-r--r-- | gerber/tests/test_primitives.py | 111 | ||||
-rw-r--r-- | gerber/tests/test_rs274x_backend.py | 38 | ||||
-rw-r--r-- | gerber/utils.py | 7 |
12 files changed, 956 insertions, 471 deletions
diff --git a/gerber/excellon.py b/gerber/excellon.py index 9825c5a..c3de948 100755 --- a/gerber/excellon.py +++ b/gerber/excellon.py @@ -100,12 +100,12 @@ class DrillHit(object): self.position = position
def to_inch(self):
- if self.tool.units == 'metric':
+ if self.tool.settings.units == 'metric':
self.tool.to_inch()
self.position = tuple(map(inch, self.position))
def to_metric(self):
- if self.tool.units == 'inch':
+ if self.tool.settings.units == 'inch':
self.tool.to_metric()
self.position = tuple(map(metric, self.position))
@@ -120,7 +120,7 @@ class DrillHit(object): max_y = position[1] + radius
return ((min_x, max_x), (min_y, max_y))
- def offset(self, x_offset, y_offset):
+ def offset(self, x_offset=0, y_offset=0):
self.position = tuple(map(operator.add, self.position, (x_offset, y_offset)))
def __str__(self):
@@ -141,13 +141,13 @@ class DrillSlot(object): self.slot_type = slot_type
def to_inch(self):
- if self.tool.units == 'metric':
+ if self.tool.settings.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':
+ if self.tool.settings.units == 'inch':
self.tool.to_metric()
self.start = tuple(map(metric, self.start))
self.end = tuple(map(metric, self.end))
@@ -163,7 +163,7 @@ class DrillSlot(object): max_y = max(start[1], end[1]) + radius
return ((min_x, max_x), (min_y, max_y))
- def offset(self, x_offset, y_offset):
+ def offset(self, x_offset=0, y_offset=0):
self.start = tuple(map(operator.add, self.start, (x_offset, y_offset)))
self.end = tuple(map(operator.add, self.end, (x_offset, y_offset)))
@@ -183,6 +183,7 @@ class ExcellonFile(CamFile): hits : list of tuples
list of drill hits as (<Tool>, (x, y))
+
settings : dict
Dictionary of gerber file settings
@@ -211,16 +212,17 @@ class ExcellonFile(CamFile): primitives = []
for hit in self.hits:
if isinstance(hit, DrillHit):
- primitives.append(Drill(hit.position, hit.tool.diameter, hit, units=self.settings.units))
+ primitives.append(Drill(hit.position, hit.tool.diameter,
+ units=self.settings.units))
elif isinstance(hit, DrillSlot):
- primitives.append(Slot(hit.start, hit.end, hit.tool.diameter, hit, units=self.settings.units))
+ primitives.append(Slot(hit.start, hit.end, hit.tool.diameter,
+ units=self.settings.units))
else:
raise ValueError('Unknown hit type')
-
return primitives
@property
- def bounds(self):
+ def bounding_box(self):
xmin = ymin = 100000000000
xmax = ymax = -100000000000
for hit in self.hits:
@@ -282,29 +284,31 @@ class ExcellonFile(CamFile): Convert units to inches
"""
if self.units != 'inch':
- self.units = 'inch'
for statement in self.statements:
statement.to_inch()
for tool in iter(self.tools.values()):
tool.to_inch()
- for primitive in self.primitives:
- primitive.to_inch()
- for hit in self.hits:
- hit.to_inch()
+ #for primitive in self.primitives:
+ # primitive.to_inch()
+ #for hit in self.hits:
+ # hit.to_inch()
+ self.units = 'inch'
def to_metric(self):
""" Convert units to metric
"""
if self.units != 'metric':
- self.units = 'metric'
for statement in self.statements:
statement.to_metric()
for tool in iter(self.tools.values()):
tool.to_metric()
- for primitive in self.primitives:
- primitive.to_metric()
+ #for primitive in self.primitives:
+ # print("Converting to metric: {}".format(primitive))
+ # primitive.to_metric()
+ # print(primitive)
for hit in self.hits:
hit.to_metric()
+ self.units = 'metric'
def offset(self, x_offset=0, y_offset=0):
for statement in self.statements:
@@ -663,7 +667,8 @@ class ExcellonParser(object): 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
+ # 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
@@ -835,7 +840,7 @@ def detect_excellon_format(data=None, filename=None): try:
p = ExcellonParser(settings)
ef = p.parse_raw(data)
- size = tuple([t[0] - t[1] for t in ef.bounds])
+ size = tuple([t[0] - t[1] for t in ef.bounding_box])
hole_area = 0.0
for hit in p.hits:
tool = hit.tool
diff --git a/gerber/excellon_statements.py b/gerber/excellon_statements.py index ac9c528..bcf35e4 100644 --- a/gerber/excellon_statements.py +++ b/gerber/excellon_statements.py @@ -113,16 +113,16 @@ 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 @@ -131,7 +131,7 @@ class ExcellonTool(ExcellonStatement): args['plated'] = tool.plated args['retract_rate'] = tool.retract_rate args['rpm'] = tool.rpm - + return cls(None, **args) @classmethod @@ -172,9 +172,9 @@ 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 + # Sometimees we can can parse the plating status args['plated'] = plated return cls(settings, **args) @@ -209,7 +209,7 @@ class ExcellonTool(ExcellonStatement): 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): @@ -249,15 +249,15 @@ 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 @@ -314,12 +314,12 @@ 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. @@ -329,10 +329,10 @@ class NextToolSelectionStmt(ExcellonStatement): 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 @@ -651,11 +651,11 @@ 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 @@ -742,7 +742,7 @@ 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) @@ -844,38 +844,38 @@ class UnknownStmt(ExcellonStatement): 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 - + 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, @@ -886,7 +886,7 @@ class SlotStmt(ExcellonStatement): else: y_coord = parse_gerber_value(line.strip(' Y'), settings.format, settings.zero_suppression) - + return (x_coord, y_coord) @@ -907,16 +907,16 @@ class SlotStmt(ExcellonStatement): 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): @@ -959,7 +959,7 @@ class SlotStmt(ExcellonStatement): 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 diff --git a/gerber/excellon_tool.py b/gerber/excellon_tool.py index bd76e54..a9ac450 100644 --- a/gerber/excellon_tool.py +++ b/gerber/excellon_tool.py @@ -28,9 +28,9 @@ 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 @@ -52,13 +52,13 @@ class ExcellonToolDefinitionParser(object): ---------- 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'), @@ -66,34 +66,34 @@ class ExcellonToolDefinitionParser(object): (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': @@ -102,19 +102,20 @@ class ExcellonToolDefinitionParser(object): plated = ExcellonTool.PLATED_OPTIONAL else: plated = ExcellonTool.PLATED_UNKNOWN - - tool = ExcellonTool(None, number=toolid, diameter=size, plated=plated) - + + 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 @@ -137,34 +138,35 @@ def loads_rep(data, settings=None): 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. + # 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() @@ -180,7 +182,9 @@ class ExcellonReportParser(object): feedrate = int(parts[3]) speed = int(parts[4]) qty = int(parts[5]) - - tool = ExcellonTool(None, number=toolid, diameter=size, plated=plated, feed_rate=feedrate, rpm=speed) - - self.tools[tool.number] = tool
\ No newline at end of file + + tool = ExcellonTool(None, number=toolid, diameter=size, + plated=plated, feed_rate=feedrate, + rpm=speed) + + self.tools[tool.number] = tool diff --git a/gerber/gerber_statements.py b/gerber/gerber_statements.py index 7322b3c..43596be 100644 --- a/gerber/gerber_statements.py +++ b/gerber/gerber_statements.py @@ -279,22 +279,36 @@ class ADParamStmt(ParamStmt): return cls('AD', dcode, 'R', ([width, height],)) @classmethod - def circle(cls, dcode, diameter, hole_diameter): + def circle(cls, dcode, diameter, hole_diameter=None, hole_width=None, hole_height=None): '''Create a circular aperture definition statement''' - if hole_diameter != None: + if hole_diameter is not None and hole_diameter > 0: return cls('AD', dcode, 'C', ([diameter, hole_diameter],)) + elif (hole_width is not None and hole_width > 0 + and hole_height is not None and hole_height > 0): + return cls('AD', dcode, 'C', ([diameter, hole_width, hole_height],)) return cls('AD', dcode, 'C', ([diameter],)) @classmethod - def obround(cls, dcode, width, height): + def obround(cls, dcode, width, height, hole_diameter=None, hole_width=None, hole_height=None): '''Create an obround aperture definition statement''' + if hole_diameter is not None and hole_diameter > 0: + return cls('AD', dcode, 'O', ([width, height, hole_diameter],)) + elif (hole_width is not None and hole_width > 0 + and hole_height is not None and hole_height > 0): + return cls('AD', dcode, 'O', ([width, height, hole_width, hole_height],)) return cls('AD', dcode, 'O', ([width, height],)) @classmethod - def polygon(cls, dcode, diameter, num_vertices, rotation, hole_diameter): + def polygon(cls, dcode, diameter, num_vertices, rotation, hole_diameter=None, hole_width=None, hole_height=None): '''Create a polygon aperture definition statement''' - return cls('AD', dcode, 'P', ([diameter, num_vertices, rotation, hole_diameter],)) + if hole_diameter is not None and hole_diameter > 0: + return cls('AD', dcode, 'P', ([diameter, num_vertices, rotation, hole_diameter],)) + elif (hole_width is not None and hole_width > 0 + and hole_height is not None and hole_height > 0): + return cls('AD', dcode, 'P', ([diameter, num_vertices, rotation, hole_width, hole_height],)) + return cls('AD', dcode, 'P', ([diameter, num_vertices, rotation],)) + @classmethod def macro(cls, dcode, name): diff --git a/gerber/primitives.py b/gerber/primitives.py index bd93e04..f583ca9 100644 --- a/gerber/primitives.py +++ b/gerber/primitives.py @@ -64,7 +64,6 @@ class Primitive(object): @property
def flashed(self):
'''Is this a flashed primitive'''
-
raise NotImplementedError('Is flashed must be '
'implemented in subclass')
@@ -271,9 +270,9 @@ class Line(Primitive): @property
def vertices(self):
if self._vertices is None:
+ start = self.start
+ end = self.end
if isinstance(self.aperture, Rectangle):
- start = self.start
- end = self.end
width = self.aperture.width
height = self.aperture.height
@@ -289,6 +288,11 @@ class Line(Primitive): # The line is defined by the convex hull of the points
self._vertices = convex_hull((start_ll, start_lr, start_ul, start_ur, end_ll, end_lr, end_ul, end_ur))
+ elif isinstance(self.aperture, Polygon):
+ points = [map(add, point, vertex)
+ for vertex in self.aperture.vertices
+ for point in (start, end)]
+ self._vertices = convex_hull(points)
return self._vertices
def offset(self, x_offset=0, y_offset=0):
@@ -309,11 +313,18 @@ class Line(Primitive): return nearly_equal(self.start, equiv_start) and nearly_equal(self.end, equiv_end)
+ def __str__(self):
+ return "<Line {} to {}>".format(self.start, self.end)
+
+ def __repr__(self):
+ return str(self)
+
class Arc(Primitive):
"""
"""
- def __init__(self, start, end, center, direction, aperture, quadrant_mode, **kwargs):
+ def __init__(self, start, end, center, direction, aperture, quadrant_mode,
+ **kwargs):
super(Arc, self).__init__(**kwargs)
self._start = start
self._end = end
@@ -371,15 +382,15 @@ class Arc(Primitive): @property
def start_angle(self):
- dy, dx = tuple([start - center for start, center
+ dx, dy = tuple([start - center for start, center
in zip(self.start, self.center)])
- return math.atan2(dx, dy)
+ return math.atan2(dy, dx)
@property
def end_angle(self):
- dy, dx = tuple([end - center for end, center
+ dx, dy = tuple([end - center for end, center
in zip(self.end, self.center)])
- return math.atan2(dx, dy)
+ return math.atan2(dy, dx)
@property
def sweep_angle(self):
@@ -399,77 +410,98 @@ class Arc(Primitive): theta0 = (self.start_angle + two_pi) % two_pi
theta1 = (self.end_angle + two_pi) % two_pi
points = [self.start, self.end]
+ if self.quadrant_mode == 'multi-quadrant':
+ 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)))
+ or ((theta1 > math.pi / 2.) and (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))
+ or ((theta1 > math.pi) and (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)
+ or ((theta1 > math.pi * 1.5) and (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))
+ or ((theta0 > math.pi / 2.) and (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))
+ or ((theta0 > math.pi) and (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))
+ or ((theta0 > math.pi * 1.5) and (theta0 <= theta1))):
+ points.append((self.center[0], self.center[1] - self.radius))
+ x, y = zip(*points)
+ if hasattr(self.aperture, 'radius'):
+ min_x = min(x) - self.aperture.radius
+ max_x = max(x) + self.aperture.radius
+ min_y = min(y) - self.aperture.radius
+ max_y = max(y) + self.aperture.radius
+ else:
+ min_x = min(x) - self.aperture.width
+ max_x = max(x) + self.aperture.width
+ min_y = min(y) - self.aperture.height
+ max_y = max(y) + self.aperture.height
+
+ 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.quadrant_mode == 'multi-quadrant':
if self.direction == 'counterclockwise':
# Passes through 0 degrees
- if theta0 > theta1:
+ 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):
+ if (((theta0 <= math.pi / 2.) and (
+ (theta1 >= math.pi / 2.) or (theta1 <= theta0)))
+ or ((theta1 > math.pi / 2.) and (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):
+ if ((theta0 <= math.pi and (theta1 >= math.pi or theta1 <= theta0))
+ or ((theta1 > math.pi) and (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):
+ if (theta0 <= math.pi * 1.5 and (
+ theta1 >= math.pi * 1.5 or theta1 <= theta0)
+ or ((theta1 > math.pi * 1.5) and (theta1 <= theta0))):
points.append((self.center[0], self.center[1] - self.radius))
else:
# Passes through 0 degrees
- if theta1 > theta0:
+ 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):
+ if (((theta1 <= math.pi / 2.) and (
+ theta0 >= math.pi / 2. or theta0 <= theta1))
+ or ((theta0 > math.pi / 2.) and (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):
+ if (((theta1 <= math.pi) and (theta0 >= math.pi or theta0 <= theta1))
+ or ((theta0 > math.pi) and (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):
+ if (((theta1 <= math.pi * 1.5) and (
+ theta0 >= math.pi * 1.5 or theta0 <= theta1))
+ or ((theta0 > math.pi * 1.5) and (theta0 <= theta1))):
points.append((self.center[0], self.center[1] - self.radius))
- x, y = zip(*points)
- min_x = min(x) - self.aperture.radius
- max_x = max(x) + self.aperture.radius
- min_y = min(y) - self.aperture.radius
- max_y = max(y) + self.aperture.radius
- 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)
@@ -489,13 +521,16 @@ class Circle(Primitive): """
"""
- def __init__(self, position, diameter, hole_diameter = None, **kwargs):
+ def __init__(self, position, diameter, hole_diameter=None,
+ hole_width=0, hole_height=0, **kwargs):
super(Circle, self).__init__(**kwargs)
validate_coordinates(position)
self._position = position
self._diameter = diameter
self.hole_diameter = hole_diameter
- self._to_convert = ['position', 'diameter', 'hole_diameter']
+ self.hole_width = hole_width
+ self.hole_height = hole_height
+ self._to_convert = ['position', 'diameter', 'hole_diameter', 'hole_width', 'hole_height']
@property
def flashed(self):
@@ -631,14 +666,18 @@ class Rectangle(Primitive): then you don't need to worry about rotation
"""
- def __init__(self, position, width, height, hole_diameter=0, **kwargs):
+ def __init__(self, position, width, height, hole_diameter=0,
+ hole_width=0, hole_height=0, **kwargs):
super(Rectangle, self).__init__(**kwargs)
validate_coordinates(position)
self._position = position
self._width = width
self._height = height
self.hole_diameter = hole_diameter
- self._to_convert = ['position', 'width', 'height', 'hole_diameter']
+ self.hole_width = hole_width
+ self.hole_height = hole_height
+ self._to_convert = ['position', 'width', 'height', 'hole_diameter',
+ 'hole_width', 'hole_height']
# TODO These are probably wrong when rotated
self._lower_left = None
self._upper_right = None
@@ -736,6 +775,12 @@ class Rectangle(Primitive): return nearly_equal(self.position, equiv_position)
+ def __str__(self):
+ return "<Rectangle W {} H {} R {}>".format(self.width, self.height, self.rotation * 180/math.pi)
+
+ def __repr__(self):
+ return self.__str__()
+
class Diamond(Primitive):
"""
@@ -898,7 +943,8 @@ class ChamferRectangle(Primitive): ((self.position[0] - delta_w), (self.position[1] - delta_h)),
((self.position[0] + delta_w), (self.position[1] - delta_h))
]
- for idx, corner, chamfered in enumerate((rect_corners, self.corners)):
+ for idx, params in enumerate(zip(rect_corners, self.corners)):
+ corner, chamfered = params
x, y = corner
if chamfered:
if idx == 0:
@@ -1019,14 +1065,18 @@ class Obround(Primitive): """
"""
- def __init__(self, position, width, height, hole_diameter=0, **kwargs):
+ def __init__(self, position, width, height, hole_diameter=0,
+ hole_width=0,hole_height=0, **kwargs):
super(Obround, self).__init__(**kwargs)
validate_coordinates(position)
self._position = position
self._width = width
self._height = height
self.hole_diameter = hole_diameter
- self._to_convert = ['position', 'width', 'height', 'hole_diameter']
+ self.hole_width = hole_width
+ self.hole_height = hole_height
+ self._to_convert = ['position', 'width', 'height', 'hole_diameter',
+ 'hole_width', 'hole_height' ]
@property
def flashed(self):
@@ -1116,14 +1166,18 @@ class Polygon(Primitive): """
Polygon flash defined by a set number of sides.
"""
- def __init__(self, position, sides, radius, hole_diameter, **kwargs):
+ def __init__(self, position, sides, radius, hole_diameter=0,
+ hole_width=0, hole_height=0, **kwargs):
super(Polygon, self).__init__(**kwargs)
validate_coordinates(position)
self._position = position
self.sides = sides
self._radius = radius
self.hole_diameter = hole_diameter
- self._to_convert = ['position', 'radius', 'hole_diameter']
+ self.hole_width = hole_width
+ self.hole_height = hole_height
+ self._to_convert = ['position', 'radius', 'hole_diameter',
+ 'hole_width', 'hole_height']
@property
def flashed(self):
@@ -1174,25 +1228,14 @@ class Polygon(Primitive): def vertices(self):
offset = self.rotation
- da = 360.0 / self.sides
+ delta_angle = 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))
-
+ for i in range(self.sides):
+ points.append(
+ rotate_point((self.position[0] + self.radius, self.position[1]), offset + delta_angle * i, self.position))
return points
- @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
def equivalent(self, other, offset):
"""
@@ -1555,15 +1598,12 @@ class SquareRoundDonut(Primitive): class Drill(Primitive):
""" A drill hole
"""
- def __init__(self, position, diameter, hit, **kwargs):
+ def __init__(self, position, diameter, **kwargs):
super(Drill, self).__init__('dark', **kwargs)
validate_coordinates(position)
self._position = position
self._diameter = diameter
- self.hit = hit
- self._to_convert = ['position', 'diameter', 'hit']
-
- # TODO Ths won't handle the hit updates correctly
+ self._to_convert = ['position', 'diameter']
@property
def flashed(self):
@@ -1606,23 +1646,21 @@ class Drill(Primitive): 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)
+ return '<Drill %f %s (%f, %f)>' % (self.diameter, self.units, self.position[0], self.position[1])
class Slot(Primitive):
""" A drilled slot
"""
- def __init__(self, start, end, diameter, hit, **kwargs):
+ def __init__(self, start, end, diameter, **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']
+ self._to_convert = ['start', 'end', 'diameter']
- # TODO this needs to use cached bounding box
@property
def flashed(self):
@@ -1630,8 +1668,8 @@ class Slot(Primitive): 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])
+ ll = tuple([c - self.diameter / 2. for c in self.position])
+ ur = tuple([c + self.diameter / 2. for c in self.position])
self._bounding_box = ((ll[0], ur[0]), (ll[1], ur[1]))
return self._bounding_box
diff --git a/gerber/render/cairo_backend.py b/gerber/render/cairo_backend.py index 31a1e77..a2baa47 100644 --- a/gerber/render/cairo_backend.py +++ b/gerber/render/cairo_backend.py @@ -1,4 +1,4 @@ -#! /usr/bin/env python
+#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright 2014 Hamilton Kibbe <ham@hamiltonkib.be>
@@ -29,6 +29,7 @@ import os from .render import GerberContext, RenderSettings
from .theme import THEMES
from ..primitives import *
+from ..utils import rotate_point
from io import BytesIO
@@ -67,16 +68,13 @@ class GerberCairoContext(GerberContext): size_in_pixels = self.scale_point(size_in_inch)
self.origin_in_inch = origin_in_inch if self.origin_in_inch is None else self.origin_in_inch
self.size_in_inch = size_in_inch if self.size_in_inch is None else self.size_in_inch
+ self._xform_matrix = cairo.Matrix(xx=1.0, yy=-1.0,
+ x0=-self.origin_in_pixels[0],
+ y0=self.size_in_pixels[1])
if (self.surface is None) or new_surface:
self.surface_buffer = tempfile.NamedTemporaryFile()
self.surface = cairo.SVGSurface(self.surface_buffer, size_in_pixels[0], size_in_pixels[1])
self.output_ctx = cairo.Context(self.surface)
- self.output_ctx.scale(1, -1)
- self.output_ctx.translate(-(origin_in_inch[0] * self.scale[0]),
- (-origin_in_inch[1] * self.scale[0]) - size_in_pixels[1])
- self._xform_matrix = cairo.Matrix(xx=1.0, yy=-1.0,
- x0=-self.origin_in_pixels[0],
- y0=self.size_in_pixels[1] + self.origin_in_pixels[1])
def render_layer(self, layer, filename=None, settings=None, bgsettings=None,
verbose=False):
@@ -155,6 +153,23 @@ class GerberCairoContext(GerberContext): self.surface_buffer.close()
self.surface_buffer = None
+ def _new_mask(self):
+ class Mask:
+ def __enter__(msk):
+ size_in_pixels = self.size_in_pixels
+ msk.surface = cairo.SVGSurface(None, size_in_pixels[0],
+ size_in_pixels[1])
+ msk.ctx = cairo.Context(msk.surface)
+ msk.ctx.translate(-self.origin_in_pixels[0], -self.origin_in_pixels[1])
+ return msk
+
+
+ def __exit__(msk, exc_type, exc_val, traceback):
+ if hasattr(msk.surface, 'finish'):
+ msk.surface.finish()
+
+ return Mask()
+
def _render_layer(self, layer, settings):
self.invert = settings.invert
# Get a new clean layer to render on
@@ -167,31 +182,36 @@ class GerberCairoContext(GerberContext): def _render_line(self, line, color):
start = [pos * scale for pos, scale in zip(line.start, self.scale)]
end = [pos * scale for pos, scale in zip(line.end, self.scale)]
- self.ctx.set_operator(cairo.OPERATOR_SOURCE
- if line.level_polarity == 'dark' and
- (not self.invert) else cairo.OPERATOR_CLEAR)
- if isinstance(line.aperture, Circle):
- width = line.aperture.diameter
- 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()
- elif isinstance(line.aperture, Rectangle):
- points = [self.scale_point(x) for x in line.vertices]
- self.ctx.set_line_width(0)
- self.ctx.move_to(*points[0])
- for point in points[1:]:
- self.ctx.line_to(*point)
- self.ctx.fill()
+ self.ctx.set_operator(cairo.OPERATOR_OVER
+ if (not self.invert)
+ and line.level_polarity == 'dark'
+ else cairo.OPERATOR_CLEAR)
+ with self._new_mask() as mask:
+ if isinstance(line.aperture, Circle):
+ width = line.aperture.diameter
+ mask.ctx.set_line_width(width * self.scale[0])
+ mask.ctx.set_line_cap(cairo.LINE_CAP_ROUND)
+ mask.ctx.move_to(*start)
+ mask.ctx.line_to(*end)
+ mask.ctx.stroke()
+
+ elif hasattr(line, 'vertices') and line.vertices is not None:
+ points = [self.scale_point(x) for x in line.vertices]
+ mask.ctx.set_line_width(0)
+ mask.ctx.move_to(*points[-1])
+ for point in points:
+ mask.ctx.line_to(*point)
+ mask.ctx.fill()
+ self.ctx.mask_surface(mask.surface, self.origin_in_pixels[0])
def _render_arc(self, arc, color):
center = self.scale_point(arc.center)
start = self.scale_point(arc.start)
end = self.scale_point(arc.end)
radius = self.scale[0] * arc.radius
- angle1 = arc.start_angle
- angle2 = arc.end_angle
+ two_pi = 2 * math.pi
+ angle1 = (arc.start_angle + two_pi) % two_pi
+ angle2 = (arc.end_angle + two_pi) % two_pi
if angle1 == angle2 and arc.quadrant_mode != 'single-quadrant':
# Make the angles slightly different otherwise Cario will draw nothing
angle2 -= 0.000000001
@@ -200,61 +220,111 @@ class GerberCairoContext(GerberContext): 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)
- self.ctx.set_line_width(width * self.scale[0])
- self.ctx.set_line_cap(cairo.LINE_CAP_ROUND)
- self.ctx.move_to(*start) # You actually have to do this...
- if arc.direction == 'counterclockwise':
- self.ctx.arc(*center, radius=radius, angle1=angle1, angle2=angle2)
- else:
- self.ctx.arc_negative(*center, radius=radius,
- angle1=angle1, angle2=angle2)
- self.ctx.move_to(*end) # ...lame
+ self.ctx.set_operator(cairo.OPERATOR_OVER
+ if (not self.invert)
+ and arc.level_polarity == 'dark'
+ else cairo.OPERATOR_CLEAR)
+
+ with self._new_mask() as mask:
+ mask.ctx.set_line_width(width * self.scale[0])
+ mask.ctx.set_line_cap(cairo.LINE_CAP_ROUND if isinstance(arc.aperture, Circle) else cairo.LINE_CAP_SQUARE)
+ mask.ctx.move_to(*start) # You actually have to do this...
+ if arc.direction == 'counterclockwise':
+ mask.ctx.arc(*center, radius=radius, angle1=angle1, angle2=angle2)
+ else:
+ mask.ctx.arc_negative(*center, radius=radius,
+ angle1=angle1, angle2=angle2)
+ mask.ctx.move_to(*end) # ...lame
+ mask.ctx.stroke()
+
+ #if isinstance(arc.aperture, Rectangle):
+ # print("Flash Rectangle Ends")
+ # print(arc.aperture.rotation * 180/math.pi)
+ # rect = arc.aperture
+ # width = self.scale[0] * rect.width
+ # height = self.scale[1] * rect.height
+ # for point, angle in zip((start, end), (angle1, angle2)):
+ # print("{} w {} h{}".format(point, rect.width, rect.height))
+ # mask.ctx.rectangle(point[0] - width/2.0,
+ # point[1] - height/2.0, width, height)
+ # mask.ctx.fill()
+
+ self.ctx.mask_surface(mask.surface, self.origin_in_pixels[0])
+
def _render_region(self, region, color):
- self.ctx.set_operator(cairo.OPERATOR_SOURCE
- if region.level_polarity == 'dark' and
- (not self.invert) else cairo.OPERATOR_CLEAR)
- self.ctx.set_line_width(0)
- self.ctx.set_line_cap(cairo.LINE_CAP_ROUND)
- self.ctx.move_to(*self.scale_point(region.primitives[0].start))
- for prim in region.primitives:
- if isinstance(prim, Line):
- self.ctx.line_to(*self.scale_point(prim.end))
- else:
- center = self.scale_point(prim.center)
- radius = self.scale[0] * prim.radius
- angle1 = prim.start_angle
- angle2 = prim.end_angle
- if prim.direction == 'counterclockwise':
- self.ctx.arc(*center, radius=radius,
- angle1=angle1, angle2=angle2)
+ self.ctx.set_operator(cairo.OPERATOR_OVER
+ if (not self.invert) and region.level_polarity == 'dark'
+ else cairo.OPERATOR_CLEAR)
+ with self._new_mask() as mask:
+ mask.ctx.set_line_width(0)
+ mask.ctx.set_line_cap(cairo.LINE_CAP_ROUND)
+ mask.ctx.move_to(*self.scale_point(region.primitives[0].start))
+ for prim in region.primitives:
+ if isinstance(prim, Line):
+ mask.ctx.line_to(*self.scale_point(prim.end))
else:
- self.ctx.arc_negative(*center, radius=radius,
- angle1=angle1, angle2=angle2)
- self.ctx.fill()
+ center = self.scale_point(prim.center)
+ radius = self.scale[0] * prim.radius
+ angle1 = prim.start_angle
+ angle2 = prim.end_angle
+ if prim.direction == 'counterclockwise':
+ mask.ctx.arc(*center, radius=radius,
+ angle1=angle1, angle2=angle2)
+ else:
+ mask.ctx.arc_negative(*center, radius=radius,
+ angle1=angle1, angle2=angle2)
+ mask.ctx.fill()
+ self.ctx.mask_surface(mask.surface, self.origin_in_pixels[0])
def _render_circle(self, circle, color):
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)
- 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()
+ self.ctx.set_operator(cairo.OPERATOR_OVER
+ if (not self.invert)
+ and circle.level_polarity == 'dark'
+ else cairo.OPERATOR_CLEAR)
+
+ with self._new_mask() as mask:
+ mask.ctx.set_line_width(0)
+ mask.ctx.arc(center[0],
+ center[1],
+ radius=(circle.radius * self.scale[0]),
+ angle1=0,
+ angle2=(2 * math.pi))
+ mask.ctx.fill()
+
+ if hasattr(circle, 'hole_diameter') and circle.hole_diameter > 0:
+ mask.ctx.set_operator(cairo.OPERATOR_CLEAR)
+ mask.ctx.arc(center[0],
+ center[1],
+ radius=circle.hole_radius * self.scale[0],
+ angle1=0,
+ angle2=2 * math.pi)
+ mask.ctx.fill()
+
+ if (hasattr(circle, 'hole_width') and hasattr(circle, 'hole_height')
+ and circle.hole_width > 0 and circle.hole_height > 0):
+ mask.ctx.set_operator(cairo.OPERATOR_CLEAR
+ if circle.level_polarity == 'dark'
+ and (not self.invert)
+ else cairo.OPERATOR_OVER)
+ width, height = self.scale_point((circle.hole_width, circle.hole_height))
+ lower_left = rotate_point(
+ (center[0] - width / 2.0, center[1] - height / 2.0),
+ circle.rotation, center)
+ lower_right = rotate_point((center[0] + width / 2.0, center[1] - height / 2.0),
+ circle.rotation, center)
+ upper_left = rotate_point((center[0] - width / 2.0, center[1] + height / 2.0),
+ circle.rotation, center)
+ upper_right = rotate_point((center[0] + width / 2.0, center[1] + height / 2.0),
+ circle.rotation, center)
+ points = (lower_left, lower_right, upper_right, upper_left)
+ mask.ctx.move_to(*points[-1])
+ for point in points:
+ mask.ctx.line_to(*point)
+ mask.ctx.fill()
+
+ self.ctx.mask_surface(mask.surface, self.origin_in_pixels[0])
def _render_rectangle(self, rectangle, color):
@@ -262,101 +332,156 @@ class GerberCairoContext(GerberContext): width, height = tuple([abs(coord) for coord in
self.scale_point((rectangle.width,
rectangle.height))])
- 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()
+ self.ctx.set_operator(cairo.OPERATOR_OVER
+ if (not self.invert)
+ and rectangle.level_polarity == 'dark'
+ else cairo.OPERATOR_CLEAR)
+ with self._new_mask() as mask:
+
+ mask.ctx.set_line_width(0)
+ mask.ctx.rectangle(*lower_left, width=width, height=height)
+ mask.ctx.fill()
+
+ center = self.scale_point(rectangle.position)
+ if rectangle.hole_diameter > 0:
+ # Render the center clear
+ mask.ctx.set_operator(cairo.OPERATOR_CLEAR
+ if rectangle.level_polarity == 'dark'
+ and (not self.invert)
+ else cairo.OPERATOR_OVER)
+
+ mask.ctx.arc(center[0], center[1],
+ radius=rectangle.hole_radius * self.scale[0], angle1=0,
+ angle2=2 * math.pi)
+ mask.ctx.fill()
+
+ if rectangle.hole_width > 0 and rectangle.hole_height > 0:
+ mask.ctx.set_operator(cairo.OPERATOR_CLEAR
+ if rectangle.level_polarity == 'dark'
+ and (not self.invert)
+ else cairo.OPERATOR_OVER)
+ width, height = self.scale_point((rectangle.hole_width, rectangle.hole_height))
+ lower_left = rotate_point((center[0] - width/2.0, center[1] - height/2.0), rectangle.rotation, center)
+ lower_right = rotate_point((center[0] + width/2.0, center[1] - height/2.0), rectangle.rotation, center)
+ upper_left = rotate_point((center[0] - width / 2.0, center[1] + height / 2.0), rectangle.rotation, center)
+ upper_right = rotate_point((center[0] + width / 2.0, center[1] + height / 2.0), rectangle.rotation, center)
+ points = (lower_left, lower_right, upper_right, upper_left)
+ mask.ctx.move_to(*points[-1])
+ for point in points:
+ mask.ctx.line_to(*point)
+ mask.ctx.fill()
+
+ self.ctx.mask_surface(mask.surface, self.origin_in_pixels[0])
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)
-
+ self.ctx.set_operator(cairo.OPERATOR_OVER
+ if (not self.invert)
+ and obround.level_polarity == 'dark'
+ else cairo.OPERATOR_CLEAR)
+ with self._new_mask() as mask:
+ mask.ctx.set_line_width(0)
+
+ # Render circles
+ for circle in (obround.subshapes['circle1'], obround.subshapes['circle2']):
+ center = self.scale_point(circle.position)
+ mask.ctx.arc(center[0],
+ center[1],
+ radius=(circle.radius * self.scale[0]),
+ angle1=0,
+ angle2=(2 * math.pi))
+ mask.ctx.fill()
+
+ # Render Rectangle
+ rectangle = obround.subshapes['rectangle']
+ lower_left = self.scale_point(rectangle.lower_left)
+ width, height = tuple([abs(coord) for coord in
+ self.scale_point((rectangle.width,
+ rectangle.height))])
+ mask.ctx.rectangle(*lower_left, width=width, height=height)
+ mask.ctx.fill()
+
+ center = self.scale_point(obround.position)
+ if obround.hole_diameter > 0:
+ # Render the center clear
+ mask.ctx.set_operator(cairo.OPERATOR_CLEAR)
+ mask.ctx.arc(center[0], center[1],
+ radius=obround.hole_radius * self.scale[0], angle1=0,
+ angle2=2 * math.pi)
+ mask.ctx.fill()
+
+ if obround.hole_width > 0 and obround.hole_height > 0:
+ mask.ctx.set_operator(cairo.OPERATOR_CLEAR
+ if rectangle.level_polarity == 'dark'
+ and (not self.invert)
+ else cairo.OPERATOR_OVER)
+ width, height =self.scale_point((obround.hole_width, obround.hole_height))
+ lower_left = rotate_point((center[0] - width / 2.0, center[1] - height / 2.0),
+ obround.rotation, center)
+ lower_right = rotate_point((center[0] + width / 2.0, center[1] - height / 2.0),
+ obround.rotation, center)
+ upper_left = rotate_point((center[0] - width / 2.0, center[1] + height / 2.0),
+ obround.rotation, center)
+ upper_right = rotate_point((center[0] + width / 2.0, center[1] + height / 2.0),
+ obround.rotation, center)
+ points = (lower_left, lower_right, upper_right, upper_left)
+ mask.ctx.move_to(*points[-1])
+ for point in points:
+ mask.ctx.line_to(*point)
+ mask.ctx.fill()
+
+ self.ctx.mask_surface(mask.surface, self.origin_in_pixels[0])
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()
+ self.ctx.set_operator(cairo.OPERATOR_OVER
+ if (not self.invert)
+ and polygon.level_polarity == 'dark'
+ else cairo.OPERATOR_CLEAR)
+ with self._new_mask() as mask:
+
+ vertices = polygon.vertices
+ mask.ctx.set_line_width(0)
+ mask.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
+ mask.ctx.move_to(*self.scale_point(vertices[-1]))
+ for v in vertices:
+ mask.ctx.line_to(*self.scale_point(v))
+ mask.ctx.fill()
+
+ center = self.scale_point(polygon.position)
+ if polygon.hole_radius > 0:
+ # Render the center clear
+ mask.ctx.set_operator(cairo.OPERATOR_CLEAR
+ if polygon.level_polarity == 'dark'
+ and (not self.invert)
+ else cairo.OPERATOR_OVER)
+ mask.ctx.set_line_width(0)
+ mask.ctx.arc(center[0],
+ center[1],
+ polygon.hole_radius * self.scale[0], 0, 2 * math.pi)
+ mask.ctx.fill()
+
+ if polygon.hole_width > 0 and polygon.hole_height > 0:
+ mask.ctx.set_operator(cairo.OPERATOR_CLEAR
+ if polygon.level_polarity == 'dark'
+ and (not self.invert)
+ else cairo.OPERATOR_OVER)
+ width, height = self.scale_point((polygon.hole_width, polygon.hole_height))
+ lower_left = rotate_point((center[0] - width / 2.0, center[1] - height / 2.0),
+ polygon.rotation, center)
+ lower_right = rotate_point((center[0] + width / 2.0, center[1] - height / 2.0),
+ polygon.rotation, center)
+ upper_left = rotate_point((center[0] - width / 2.0, center[1] + height / 2.0),
+ polygon.rotation, center)
+ upper_right = rotate_point((center[0] + width / 2.0, center[1] + height / 2.0),
+ polygon.rotation, center)
+ points = (lower_left, lower_right, upper_right, upper_left)
+ mask.ctx.move_to(*points[-1])
+ for point in points:
+ mask.ctx.line_to(*point)
+ mask.ctx.fill()
+
+ self.ctx.mask_surface(mask.surface, self.origin_in_pixels[0])
def _render_drill(self, circle, color=None):
color = color if color is not None else self.drill_color
@@ -368,22 +493,20 @@ class GerberCairoContext(GerberContext): width = slot.diameter
- self.ctx.set_operator(cairo.OPERATOR_SOURCE
+ self.ctx.set_operator(cairo.OPERATOR_OVER
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()
+ with self._new_mask() as mask:
+ mask.ctx.set_line_width(width * self.scale[0])
+ mask.ctx.set_line_cap(cairo.LINE_CAP_ROUND)
+ mask.ctx.move_to(*start)
+ mask.ctx.line_to(*end)
+ mask.ctx.stroke()
+ self.ctx.mask_surface(mask.surface, self.origin_in_pixels[0])
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
@@ -392,7 +515,7 @@ class GerberCairoContext(GerberContext): 'monospace', cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD)
self.ctx.set_font_size(13)
self._render_circle(Circle(position, 0.015), color)
- self.ctx.set_operator(cairo.OPERATOR_SOURCE
+ self.ctx.set_operator(cairo.OPERATOR_OVER
if primitive.level_polarity == 'dark' and
(not self.invert) else cairo.OPERATOR_CLEAR)
self.ctx.move_to(*[self.scale[0] * (coord + 0.015) for coord in position])
@@ -405,26 +528,25 @@ class GerberCairoContext(GerberContext): matrix = copy.copy(self._xform_matrix)
layer = cairo.SVGSurface(None, size_in_pixels[0], size_in_pixels[1])
ctx = cairo.Context(layer)
- ctx.scale(1, -1)
- ctx.translate(-(self.origin_in_inch[0] * self.scale[0]),
- (-self.origin_in_inch[1] * self.scale[0]) - size_in_pixels[1])
+
if self.invert:
+ ctx.set_source_rgba(0.0, 0.0, 0.0, 1.0)
ctx.set_operator(cairo.OPERATOR_OVER)
ctx.paint()
if mirror:
matrix.xx = -1.0
matrix.x0 = self.origin_in_pixels[0] + self.size_in_pixels[0]
self.ctx = ctx
+ self.ctx.set_matrix(matrix)
self.active_layer = layer
self.active_matrix = matrix
+
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)
- ptn.set_matrix(self.active_matrix)
self.output_ctx.set_source_rgba(*color, alpha=alpha)
- self.output_ctx.mask(ptn)
+ self.output_ctx.mask_surface(self.active_layer)
self.ctx = None
self.active_layer = None
self.active_matrix = None
diff --git a/gerber/render/rs274x_backend.py b/gerber/render/rs274x_backend.py index 13e871c..d32602a 100644 --- a/gerber/render/rs274x_backend.py +++ b/gerber/render/rs274x_backend.py @@ -159,7 +159,7 @@ class Rs274xContext(GerberContext): # Select the right aperture if not already selected if aperture: if isinstance(aperture, Circle): - aper = self._get_circle(aperture.diameter, aperture.hole_diameter) + aper = self._get_circle(aperture.diameter, aperture.hole_diameter, aperture.hole_width, aperture.hole_height) elif isinstance(aperture, Rectangle): aper = self._get_rectangle(aperture.width, aperture.height) elif isinstance(aperture, Obround): @@ -283,10 +283,12 @@ class Rs274xContext(GerberContext): self._pos = primitive.position - def _get_circle(self, diameter, hole_diameter, dcode = None): + def _get_circle(self, diameter, hole_diameter=None, hole_width=None, + hole_height=None, dcode = None): '''Define a circlar aperture''' - aper = self._circles.get((diameter, hole_diameter), None) + key = (diameter, hole_diameter, hole_width, hole_height) + aper = self._circles.get(key, None) if not aper: if not dcode: @@ -295,21 +297,22 @@ class Rs274xContext(GerberContext): else: self._next_dcode = max(dcode + 1, self._next_dcode) - aper = ADParamStmt.circle(dcode, diameter, hole_diameter) - self._circles[(diameter, hole_diameter)] = aper + aper = ADParamStmt.circle(dcode, diameter, hole_diameter, hole_width, hole_height) + self._circles[(diameter, hole_diameter, hole_width, hole_height)] = aper self.header.append(aper) return aper def _render_circle(self, circle, color): - aper = self._get_circle(circle.diameter, circle.hole_diameter) + aper = self._get_circle(circle.diameter, circle.hole_diameter, circle.hole_width, circle.hole_height) self._render_flash(circle, aper) - def _get_rectangle(self, width, height, dcode = None): + def _get_rectangle(self, width, height, hole_diameter=None, hole_width=None, + hole_height=None, dcode = None): '''Get a rectanglar aperture. If it isn't defined, create it''' - key = (width, height) + key = (width, height, hole_diameter, hole_width, hole_height) aper = self._rects.get(key, None) if not aper: @@ -319,20 +322,23 @@ class Rs274xContext(GerberContext): else: self._next_dcode = max(dcode + 1, self._next_dcode) - aper = ADParamStmt.rect(dcode, width, height) - self._rects[(width, height)] = aper + aper = ADParamStmt.rect(dcode, width, height, hole_diameter, hole_width, hole_height) + self._rects[(width, height, hole_diameter, hole_width, hole_height)] = aper self.header.append(aper) return aper def _render_rectangle(self, rectangle, color): - aper = self._get_rectangle(rectangle.width, rectangle.height) + aper = self._get_rectangle(rectangle.width, rectangle.height, + rectangle.hole_diameter, + rectangle.hole_width, rectangle.hole_height) self._render_flash(rectangle, aper) - def _get_obround(self, width, height, dcode = None): + def _get_obround(self, width, height, hole_diameter=None, hole_width=None, + hole_height=None, dcode = None): - key = (width, height) + key = (width, height, hole_diameter, hole_width, hole_height) aper = self._obrounds.get(key, None) if not aper: @@ -342,7 +348,7 @@ class Rs274xContext(GerberContext): else: self._next_dcode = max(dcode + 1, self._next_dcode) - aper = ADParamStmt.obround(dcode, width, height) + aper = ADParamStmt.obround(dcode, width, height, hole_diameter, hole_width, hole_height) self._obrounds[key] = aper self.header.append(aper) @@ -350,17 +356,22 @@ class Rs274xContext(GerberContext): def _render_obround(self, obround, color): - aper = self._get_obround(obround.width, obround.height) + aper = self._get_obround(obround.width, obround.height, + obround.hole_diameter, obround.hole_width, + obround.hole_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) + aper = self._get_polygon(polygon.radius, polygon.sides, + polygon.rotation, polygon.hole_diameter, + polygon.hole_width, polygon.hole_height) self._render_flash(polygon, aper) - def _get_polygon(self, radius, num_vertices, rotation, hole_radius, dcode = None): + def _get_polygon(self, radius, num_vertices, rotation, hole_diameter=None, + hole_width=None, hole_height=None, dcode = None): - key = (radius, num_vertices, rotation, hole_radius) + key = (radius, num_vertices, rotation, hole_diameter, hole_width, hole_height) aper = self._polygons.get(key, None) if not aper: @@ -370,7 +381,9 @@ class Rs274xContext(GerberContext): else: self._next_dcode = max(dcode + 1, self._next_dcode) - aper = ADParamStmt.polygon(dcode, radius * 2, num_vertices, rotation, hole_radius * 2) + aper = ADParamStmt.polygon(dcode, radius * 2, num_vertices, + rotation, hole_diameter, hole_width, + hole_height) self._polygons[key] = aper self.header.append(aper) diff --git a/gerber/rs274x.py b/gerber/rs274x.py index 5d64597..5191fb7 100644 --- a/gerber/rs274x.py +++ b/gerber/rs274x.py @@ -20,6 +20,7 @@ import copy import json +import os import re import sys @@ -146,7 +147,7 @@ class GerberFile(CamFile): return ((min_x, max_x), (min_y, max_y)) def write(self, filename, settings=None): - """ Write data out to a gerber file + """ Write data out to a gerber file. """ with open(filename, 'w') as f: for statement in self.statements: @@ -193,6 +194,9 @@ class GerberParser(object): 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) AM = r"(?P<param>AM)(?P<name>{name})\*(?P<macro>[^%]*)".format(name=NAME) + # Include File + IF = r"(?P<param>IF)(?P<filename>.*)" + # begin deprecated AS = r"(?P<param>AS)(?P<mode>(AXBY)|(AYBX))" @@ -208,7 +212,7 @@ class GerberParser(object): # end deprecated PARAMS = (FS, MO, LP, AD_CIRCLE, AD_RECT, AD_OBROUND, AD_POLY, - AD_MACRO, AM, AS, IN, IP, IR, MI, OF, SF, LN) + AD_MACRO, AM, AS, IF, IN, IP, IR, MI, OF, SF, LN) PARAM_STMT = [re.compile(r"%?{0}\*%?".format(p)) for p in PARAMS] @@ -230,7 +234,11 @@ class GerberParser(object): REGION_MODE_STMT = re.compile(r'(?P<mode>G3[67])\*') QUAD_MODE_STMT = re.compile(r'(?P<mode>G7[45])\*') + # Keep include loop from crashing us + INCLUDE_FILE_RECURSION_LIMIT = 10 + def __init__(self): + self.filename = None self.settings = FileSettings() self.statements = [] self.primitives = [] @@ -248,13 +256,16 @@ class GerberParser(object): self.region_mode = 'off' self.quadrant_mode = 'multi-quadrant' self.step_and_repeat = (1, 1, 0, 0) + self._recursion_depth = 0 def parse(self, filename): + self.filename = filename with open(filename, "rU") as fp: data = fp.read() return self.parse_raw(data, filename) def parse_raw(self, data, filename=None): + self.filename = filename for stmt in self._parse(self._split_commands(data)): self.evaluate(stmt) self.statements.append(stmt) @@ -371,6 +382,17 @@ class GerberParser(object): yield stmt elif param["param"] == "OF": yield OFParamStmt.from_dict(param) + elif param["param"] == "IF": + # Don't crash on include loop + if self._recursion_depth < self.INCLUDE_FILE_RECURSION_LIMIT: + self._recursion_depth += 1 + with open(os.path.join(os.path.dirname(self.filename), param["filename"]), 'r') as f: + inc_data = f.read() + for stmt in self._parse(self._split_commands(inc_data)): + yield stmt + self._recursion_depth -= 1 + else: + raise IOError("Include file nesting depth limit exceeded.") elif param["param"] == "IN": yield INParamStmt.from_dict(param) elif param["param"] == "LN": @@ -492,32 +514,51 @@ class GerberParser(object): if shape == 'C': diameter = modifiers[0][0] - if len(modifiers[0]) >= 2: + hole_diameter = 0 + rectangular_hole = (0, 0) + if len(modifiers[0]) == 2: hole_diameter = modifiers[0][1] - else: - hole_diameter = None + elif len(modifiers[0]) == 3: + rectangular_hole = modifiers[0][1:3] + + aperture = Circle(position=None, diameter=diameter, + hole_diameter=hole_diameter, + hole_width=rectangular_hole[0], + hole_height=rectangular_hole[1], + units=self.settings.units) - 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] - if len(modifiers[0]) >= 3: + hole_diameter = 0 + rectangular_hole = (0, 0) + 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 len(modifiers[0]) == 4: + rectangular_hole = modifiers[0][2:4] + + aperture = Rectangle(position=None, width=width, height=height, + hole_diameter=hole_diameter, + hole_width=rectangular_hole[0], + hole_height=rectangular_hole[1], + units=self.settings.units) elif shape == 'O': width = modifiers[0][0] height = modifiers[0][1] - if len(modifiers[0]) >= 3: + hole_diameter = 0 + rectangular_hole = (0, 0) + 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 len(modifiers[0]) == 4: + rectangular_hole = modifiers[0][2:4] + + aperture = Obround(position=None, width=width, height=height, + hole_diameter=hole_diameter, + hole_width=rectangular_hole[0], + hole_height=rectangular_hole[1], + units=self.settings.units) elif shape == 'P': outer_diameter = modifiers[0][0] number_vertices = int(modifiers[0][1]) @@ -526,11 +567,19 @@ class GerberParser(object): else: rotation = 0 - if len(modifiers[0]) > 3: + hole_diameter = 0 + rectangular_hole = (0, 0) + if len(modifiers[0]) == 4: 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) + elif len(modifiers[0]) >= 5: + rectangular_hole = modifiers[0][3:5] + + aperture = Polygon(position=None, sides=number_vertices, + radius=outer_diameter/2.0, + hole_diameter=hole_diameter, + hole_width=rectangular_hole[0], + hole_height=rectangular_hole[1], + rotation=rotation) else: aperture = self.macros[shape].build(modifiers) @@ -641,13 +690,18 @@ class GerberParser(object): quadrant_mode=self.quadrant_mode, level_polarity=self.level_polarity, units=self.settings.units)) + # Gerbv seems to reset interpolation mode in regions.. + # TODO: Make sure this is right. + self.interpolation = 'linear' elif self.op == "D02" or self.op == "D2": if self.region_mode == "on": # D02 in the middle of a region finishes that region and starts a new one if self.current_region and len(self.current_region) > 1: - self.primitives.append(Region(self.current_region, level_polarity=self.level_polarity, units=self.settings.units)) + self.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": @@ -672,29 +726,53 @@ class GerberParser(object): 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 + 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 + in the specified direction """ - + two_pi = 2 * math.pi if self.quadrant_mode == 'single-quadrant': + # The Gerber spec says single quadrant only has one possible center, + # and you can detect it 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). We select the center with the least error in + # radius from all the options with a valid sweep angle. - # 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]) + test_center = (start[0] + offsets[0] * factors[0], + start[1] + offsets[1] * factors[1]) + + # Find angle from center to start and end points + start_angle = math.atan2(*reversed([_start - _center for _start, _center in zip(start, test_center)])) + end_angle = math.atan2(*reversed([_end - _center for _end, _center in zip(end, test_center)])) + + # Clamp angles to 0, 2pi + theta0 = (start_angle + two_pi) % two_pi + theta1 = (end_angle + two_pi) % two_pi + # Determine sweep angle in the current arc direction + if self.direction == 'counterclockwise': + sweep_angle = abs(theta1 - theta0) + else: + theta0 += two_pi + sweep_angle = abs(theta0 - theta1) % two_pi + + # Calculate the radius error sqdist_start = sq_distance(start, test_center) sqdist_end = sq_distance(end, test_center) - if abs(sqdist_start - sqdist_end) < sqdist_diff_min: + # Take the option with the lowest radius error from the set of + # options with a valid sweep angle + if ((abs(sqdist_start - sqdist_end) < sqdist_diff_min) + and (sweep_angle >= 0) + and (sweep_angle <= math.pi / 2.0)): center = test_center sqdist_diff_min = abs(sqdist_start - sqdist_end) - return center else: return (start[0] + offsets[0], start[1] + offsets[1]) @@ -702,7 +780,6 @@ class GerberParser(object): def _evaluate_aperture(self, stmt): self.aperture = stmt.d - def _match_one(expr, data): match = expr.match(data) if match is None: diff --git a/gerber/tests/test_excellon.py b/gerber/tests/test_excellon.py index 1402938..6cddb60 100644 --- a/gerber/tests/test_excellon.py +++ b/gerber/tests/test_excellon.py @@ -6,6 +6,7 @@ import os from ..cam import FileSettings from ..excellon import read, detect_excellon_format, ExcellonFile, ExcellonParser +from ..excellon import DrillHit, DrillSlot from ..excellon_statements import ExcellonTool from .tests import * @@ -50,29 +51,28 @@ def test_read_settings(): assert_equal(ncdrill.settings['zeros'], 'trailing') -def test_bounds(): +def test_bounding_box(): ncdrill = read(NCDRILL_FILE) - xbound, ybound = ncdrill.bounds + xbound, ybound = ncdrill.bounding_box assert_array_almost_equal(xbound, (0.1300, 2.1430)) assert_array_almost_equal(ybound, (0.3946, 1.7164)) def test_report(): ncdrill = read(NCDRILL_FILE) - + rprt = ncdrill.report() def test_conversion(): import copy ncdrill = read(NCDRILL_FILE) assert_equal(ncdrill.settings.units, 'inch') ncdrill_inch = copy.deepcopy(ncdrill) + ncdrill.to_metric() assert_equal(ncdrill.settings.units, 'metric') - inch_primitives = ncdrill_inch.primitives for tool in iter(ncdrill_inch.tools.values()): tool.to_metric() - for primitive in inch_primitives: - primitive.to_metric() + for statement in ncdrill_inch.statements: statement.to_metric() @@ -80,7 +80,8 @@ def test_conversion(): iter(ncdrill_inch.tools.values())): assert_equal(i_tool, m_tool) - for m, i in zip(ncdrill.primitives, inch_primitives): + for m, i in zip(ncdrill.primitives, ncdrill_inch.primitives): + assert_equal(m.position, i.position, '%s not equal to %s' % (m, i)) assert_equal(m.diameter, i.diameter, '%s not equal to %s' % (m, i)) @@ -197,3 +198,142 @@ def test_parse_unknown(): p = ExcellonParser(FileSettings()) p._parse_line('Not A Valid Statement') assert_equal(p.statements[0].stmt, 'Not A Valid Statement') + +def test_drill_hit_units_conversion(): + """ Test unit conversion for drill hits + """ + # Inch hit + settings = FileSettings(units='inch') + tool = ExcellonTool(settings, diameter=1.0) + hit = DrillHit(tool, (1.0, 1.0)) + + assert_equal(hit.tool.settings.units, 'inch') + assert_equal(hit.tool.diameter, 1.0) + assert_equal(hit.position, (1.0, 1.0)) + + # No Effect + hit.to_inch() + + assert_equal(hit.tool.settings.units, 'inch') + assert_equal(hit.tool.diameter, 1.0) + assert_equal(hit.position, (1.0, 1.0)) + + # Should convert + hit.to_metric() + + assert_equal(hit.tool.settings.units, 'metric') + assert_equal(hit.tool.diameter, 25.4) + assert_equal(hit.position, (25.4, 25.4)) + + # No Effect + hit.to_metric() + + assert_equal(hit.tool.settings.units, 'metric') + assert_equal(hit.tool.diameter, 25.4) + assert_equal(hit.position, (25.4, 25.4)) + + # Convert back to inch + hit.to_inch() + + assert_equal(hit.tool.settings.units, 'inch') + assert_equal(hit.tool.diameter, 1.0) + assert_equal(hit.position, (1.0, 1.0)) + +def test_drill_hit_offset(): + TEST_VECTORS = [ + ((0.0 ,0.0), (0.0, 1.0), (0.0, 1.0)), + ((0.0, 0.0), (1.0, 1.0), (1.0, 1.0)), + ((1.0, 1.0), (0.0, -1.0), (1.0, 0.0)), + ((1.0, 1.0), (-1.0, -1.0), (0.0, 0.0)), + + ] + for position, offset, expected in TEST_VECTORS: + settings = FileSettings(units='inch') + tool = ExcellonTool(settings, diameter=1.0) + hit = DrillHit(tool, position) + + assert_equal(hit.position, position) + + hit.offset(offset[0], offset[1]) + + assert_equal(hit.position, expected) + + +def test_drill_slot_units_conversion(): + """ Test unit conversion for drill hits + """ + # Inch hit + settings = FileSettings(units='inch') + tool = ExcellonTool(settings, diameter=1.0) + hit = DrillSlot(tool, (1.0, 1.0), (10.0, 10.0), DrillSlot.TYPE_ROUT) + + assert_equal(hit.tool.settings.units, 'inch') + assert_equal(hit.tool.diameter, 1.0) + assert_equal(hit.start, (1.0, 1.0)) + assert_equal(hit.end, (10.0, 10.0)) + + # No Effect + hit.to_inch() + + assert_equal(hit.tool.settings.units, 'inch') + assert_equal(hit.tool.diameter, 1.0) + assert_equal(hit.start, (1.0, 1.0)) + assert_equal(hit.end, (10.0, 10.0)) + + # Should convert + hit.to_metric() + + assert_equal(hit.tool.settings.units, 'metric') + assert_equal(hit.tool.diameter, 25.4) + assert_equal(hit.start, (25.4, 25.4)) + assert_equal(hit.end, (254.0, 254.0)) + + # No Effect + hit.to_metric() + + assert_equal(hit.tool.settings.units, 'metric') + assert_equal(hit.tool.diameter, 25.4) + assert_equal(hit.start, (25.4, 25.4)) + assert_equal(hit.end, (254.0, 254.0)) + + # Convert back to inch + hit.to_inch() + + assert_equal(hit.tool.settings.units, 'inch') + assert_equal(hit.tool.diameter, 1.0) + assert_equal(hit.start, (1.0, 1.0)) + assert_equal(hit.end, (10.0, 10.0)) + +def test_drill_slot_offset(): + TEST_VECTORS = [ + ((0.0 ,0.0), (1.0, 1.0), (0.0, 0.0), (0.0, 0.0), (1.0, 1.0)), + ((0.0, 0.0), (1.0, 1.0), (1.0, 0.0), (1.0, 0.0), (2.0, 1.0)), + ((0.0, 0.0), (1.0, 1.0), (1.0, 1.0), (1.0, 1.0), (2.0, 2.0)), + ((0.0, 0.0), (1.0, 1.0), (-1.0, 1.0), (-1.0, 1.0), (0.0, 2.0)), + ] + for start, end, offset, expected_start, expected_end in TEST_VECTORS: + settings = FileSettings(units='inch') + tool = ExcellonTool(settings, diameter=1.0) + slot = DrillSlot(tool, start, end, DrillSlot.TYPE_ROUT) + + assert_equal(slot.start, start) + assert_equal(slot.end, end) + + slot.offset(offset[0], offset[1]) + + assert_equal(slot.start, expected_start) + assert_equal(slot.end, expected_end) + +def test_drill_slot_bounds(): + TEST_VECTORS = [ + ((0.0, 0.0), (1.0, 1.0), 1.0, ((-0.5, 1.5), (-0.5, 1.5))), + ((0.0, 0.0), (1.0, 1.0), 0.5, ((-0.25, 1.25), (-0.25, 1.25))), + ] + for start, end, diameter, expected, in TEST_VECTORS: + settings = FileSettings(units='inch') + tool = ExcellonTool(settings, diameter=diameter) + slot = DrillSlot(tool, start, end, DrillSlot.TYPE_ROUT) + + assert_equal(slot.bounding_box, expected) + +#def test_exce diff --git a/gerber/tests/test_primitives.py b/gerber/tests/test_primitives.py index 52d774c..2fe5a4b 100644 --- a/gerber/tests/test_primitives.py +++ b/gerber/tests/test_primitives.py @@ -192,16 +192,53 @@ def test_arc_sweep_angle(): def test_arc_bounds(): """ Test Arc primitive bounding box calculation """ - cases = [((1, 0), (0, 1), (0, 0), 'clockwise', ((-1.5, 1.5), (-1.5, 1.5))), - ((1, 0), (0, 1), (0, 0), 'counterclockwise', - ((-0.5, 1.5), (-0.5, 1.5))), - # TODO: ADD MORE TEST CASES HERE - ] + cases = [ + ((1, 0), (0, 1), (0, 0), 'clockwise', ((-1.5, 1.5), (-1.5, 1.5))), + ((1, 0), (0, 1), (0, 0), 'counterclockwise',((-0.5, 1.5), (-0.5, 1.5))), + + ((0, 1), (-1, 0), (0, 0), 'clockwise', ((-1.5, 1.5), (-1.5, 1.5))), + ((0, 1), (-1, 0), (0, 0), 'counterclockwise', ((-1.5, 0.5), (-0.5, 1.5))), + + ((-1, 0), (0, -1), (0, 0), 'clockwise', ((-1.5, 1.5), (-1.5, 1.5))), + ((-1, 0), (0, -1), (0, 0), 'counterclockwise', ((-1.5, 0.5), (-1.5, 0.5))), + + ((0, -1), (1, 0), (0, 0), 'clockwise', ((-1.5, 1.5), (-1.5, 1.5))), + ((0, -1), (1, 0), (0, 0), 'counterclockwise',((-0.5, 1.5), (-1.5, 0.5))), + + # Arcs with the same start and end point render a full circle + ((1, 0), (1, 0), (0, 0), 'clockwise', ((-1.5, 1.5), (-1.5, 1.5))), + ((1, 0), (1, 0), (0, 0), 'counterclockwise', ((-1.5, 1.5), (-1.5, 1.5))), + ] for start, end, center, direction, bounds in cases: c = Circle((0,0), 1) - a = Arc(start, end, center, direction, c, 'single-quadrant') + a = Arc(start, end, center, direction, c, 'multi-quadrant') assert_equal(a.bounding_box, bounds) +def test_arc_bounds_no_aperture(): + """ Test Arc primitive bounding box calculation ignoring aperture + """ + cases = [ + ((1, 0), (0, 1), (0, 0), 'clockwise', ((-1.0, 1.0), (-1.0, 1.0))), + ((1, 0), (0, 1), (0, 0), 'counterclockwise',((0.0, 1.0), (0.0, 1.0))), + + ((0, 1), (-1, 0), (0, 0), 'clockwise', ((-1.0, 1.0), (-1.0, 1.0))), + ((0, 1), (-1, 0), (0, 0), 'counterclockwise', ((-1.0, 0.0), (0.0, 1.0))), + + ((-1, 0), (0, -1), (0, 0), 'clockwise', ((-1.0, 1.0), (-1.0, 1.0))), + ((-1, 0), (0, -1), (0, 0), 'counterclockwise', ((-1.0, 0.0), (-1.0, 0.0))), + + ((0, -1), (1, 0), (0, 0), 'clockwise', ((-1.0, 1.0), (-1.0, 1.0))), + ((0, -1), (1, 0), (0, 0), 'counterclockwise',((-0.0, 1.0), (-1.0, 0.0))), + + # Arcs with the same start and end point render a full circle + ((1, 0), (1, 0), (0, 0), 'clockwise', ((-1.0, 1.0), (-1.0, 1.0))), + ((1, 0), (1, 0), (0, 0), 'counterclockwise', ((-1.0, 1.0), (-1.0, 1.0))), + ] + for start, end, center, direction, bounds in cases: + c = Circle((0,0), 1) + a = Arc(start, end, center, direction, c, 'multi-quadrant') + assert_equal(a.bounding_box_no_aperture, bounds) + def test_arc_conversion(): c = Circle((0, 0), 25.4, units='metric') @@ -438,6 +475,7 @@ def test_rectangle_ctor(): assert_equal(r.width, width) assert_equal(r.height, height) + def test_rectangle_hole_radius(): """ Test rectangle hole diameter calculation """ @@ -448,7 +486,6 @@ def test_rectangle_hole_radius(): assert_equal(0.5, r.hole_radius) - def test_rectangle_bounds(): """ Test rectangle bounding box calculation """ @@ -461,6 +498,32 @@ def test_rectangle_bounds(): assert_array_almost_equal(xbounds, (-math.sqrt(2), math.sqrt(2))) assert_array_almost_equal(ybounds, (-math.sqrt(2), math.sqrt(2))) +def test_rectangle_vertices(): + sqrt2 = math.sqrt(2.0) + TEST_VECTORS = [ + ((0, 0), 2.0, 2.0, 0.0, ((-1.0, -1.0), (-1.0, 1.0), (1.0, 1.0), (1.0, -1.0))), + ((0, 0), 2.0, 3.0, 0.0, ((-1.0, -1.5), (-1.0, 1.5), (1.0, 1.5), (1.0, -1.5))), + ((0, 0), 2.0, 2.0, 90.0,((-1.0, -1.0), (-1.0, 1.0), (1.0, 1.0), (1.0, -1.0))), + ((0, 0), 3.0, 2.0, 90.0,((-1.0, -1.5), (-1.0, 1.5), (1.0, 1.5), (1.0, -1.5))), + ((0, 0), 2.0, 2.0, 45.0,((-sqrt2, 0.0), (0.0, sqrt2), (sqrt2, 0), (0, -sqrt2))), + ] + for pos, width, height, rotation, expected in TEST_VECTORS: + r = Rectangle(pos, width, height, rotation=rotation) + for test, expect in zip(sorted(r.vertices), sorted(expected)): + assert_array_almost_equal(test, expect) + + r = Rectangle((0, 0), 2.0, 2.0, rotation=0.0) + r.rotation = 45.0 + for test, expect in zip(sorted(r.vertices), sorted(((-sqrt2, 0.0), (0.0, sqrt2), (sqrt2, 0), (0, -sqrt2)))): + assert_array_almost_equal(test, expect) + +def test_rectangle_segments(): + + r = Rectangle((0, 0), 2.0, 2.0) + expected = [vtx for segment in r.segments for vtx in segment] + for vertex in r.vertices: + assert_in(vertex, expected) + def test_rectangle_conversion(): """Test converting rectangles between units""" @@ -697,6 +760,18 @@ def test_chamfer_rectangle_offset(): r.offset(0, 1) assert_equal(r.position, (1., 1.)) +def test_chamfer_rectangle_vertices(): + TEST_VECTORS = [ + (1.0, (True, True, True, True), ((-2.5, -1.5), (-2.5, 1.5), (-1.5, 2.5), (1.5, 2.5), (2.5, 1.5), (2.5, -1.5), (1.5, -2.5), (-1.5, -2.5))), + (1.0, (True, False, False, False), ((-2.5, -2.5), (-2.5, 2.5), (1.5, 2.5), (2.5, 1.5), (2.5, -2.5))), + (1.0, (False, True, False, False), ((-2.5, -2.5), (-2.5, 1.5), (-1.5, 2.5), (2.5, 2.5), (2.5, -2.5))), + (1.0, (False, False, True, False), ((-2.5, -1.5), (-2.5, 2.5), (2.5, 2.5), (2.5, -2.5), (-1.5, -2.5))), + (1.0, (False, False, False, True), ((-2.5, -2.5), (-2.5, 2.5), (2.5, 2.5), (2.5, -1.5), (1.5, -2.5))), + ] + for chamfer, corners, expected in TEST_VECTORS: + r = ChamferRectangle((0, 0), 5, 5, chamfer, corners) + assert_equal(set(r.vertices), set(expected)) + def test_round_rectangle_ctor(): """ Test round rectangle creation @@ -1195,7 +1270,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, None) + d = Drill(position, diameter) assert_equal(d.position, position) assert_equal(d.diameter, diameter) assert_equal(d.radius, diameter / 2.) @@ -1204,24 +1279,24 @@ def test_drill_ctor(): def test_drill_ctor_validation(): """ Test drill argument validation """ - assert_raises(TypeError, Drill, 3, 5, None) - assert_raises(TypeError, Drill, (3,4,5), 5, None) + assert_raises(TypeError, Drill, 3, 5) + assert_raises(TypeError, Drill, (3,4,5), 5) def test_drill_bounds(): - d = Drill((0, 0), 2, None) + d = Drill((0, 0), 2) xbounds, ybounds = d.bounding_box assert_array_almost_equal(xbounds, (-1, 1)) assert_array_almost_equal(ybounds, (-1, 1)) - d = Drill((1, 2), 2, None) + d = Drill((1, 2), 2) 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., None, units='metric') + d = Drill((2.54, 25.4), 254., units='metric') #No effect d.to_metric() @@ -1237,7 +1312,7 @@ def test_drill_conversion(): assert_equal(d.position, (0.1, 1.0)) assert_equal(d.diameter, 10.0) - d = Drill((0.1, 1.0), 10., None, units='inch') + d = Drill((0.1, 1.0), 10., units='inch') # No effect d.to_inch() @@ -1255,7 +1330,7 @@ def test_drill_conversion(): def test_drill_offset(): - d = Drill((0, 0), 1., None) + d = Drill((0, 0), 1.) d.offset(1, 0) assert_equal(d.position, (1., 0.)) d.offset(0, 1) @@ -1263,8 +1338,8 @@ def test_drill_offset(): def test_drill_equality(): - d = Drill((2.54, 25.4), 254., None) - d1 = Drill((2.54, 25.4), 254., None) + d = Drill((2.54, 25.4), 254.) + d1 = Drill((2.54, 25.4), 254.) assert_equal(d, d1) - d1 = Drill((2.54, 25.4), 254.2, None) + d1 = Drill((2.54, 25.4), 254.2) assert_not_equal(d, d1) diff --git a/gerber/tests/test_rs274x_backend.py b/gerber/tests/test_rs274x_backend.py index 89512f0..e128841 100644 --- a/gerber/tests/test_rs274x_backend.py +++ b/gerber/tests/test_rs274x_backend.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # Author: Garret Fick <garret@ficksworkshop.com> -import io + import os from ..render.rs274x_backend import Rs274xContext @@ -16,7 +16,7 @@ def test_render_two_boxes(): 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') @@ -25,17 +25,17 @@ 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') @@ -43,12 +43,12 @@ def _test_render_single_contour_2(): 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""" @@ -67,7 +67,7 @@ def _test_render_overlapping_contour(): 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') @@ -96,7 +96,7 @@ 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""" @@ -141,7 +141,7 @@ def _resolve_path(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 @@ -150,14 +150,14 @@ def _test_render(gerber_path, png_expected_path, create_output_path = None): 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 + 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 @@ -165,7 +165,7 @@ def _test_render(gerber_path, png_expected_path, create_output_path = None): 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: @@ -174,12 +174,12 @@ def _test_render(gerber_path, png_expected_path, create_output_path = None): # 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 diff --git a/gerber/utils.py b/gerber/utils.py index c62ad2a..06adfd7 100644 --- a/gerber/utils.py +++ b/gerber/utils.py @@ -25,9 +25,7 @@ files. import os from math import radians, sin, cos -from operator import sub -from copy import deepcopy -from pyhull.convex_hull import ConvexHull +from scipy.spatial import ConvexHull MILLIMETERS_PER_INCH = 25.4 @@ -344,5 +342,4 @@ def listdir(directory, ignore_hidden=True, ignore_os=True): def convex_hull(points): vertices = ConvexHull(points).vertices - return [points[idx] for idx in - set([point for pair in vertices for point in pair])] + return [points[idx] for idx in vertices] |