From 41a7b90dff19b69ef03fed4104ecfdcbfcb21641 Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Fri, 18 Nov 2016 07:55:43 -0500 Subject: Excellon update --- gerber/excellon.py | 45 ++++++------ gerber/excellon_statements.py | 66 +++++++++--------- gerber/excellon_tool.py | 70 ++++++++++--------- gerber/tests/test_excellon.py | 154 ++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 242 insertions(+), 93 deletions(-) (limited to 'gerber') 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 (, (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[0-9/.]+)\s+(?PP|N)\s+T(?P[0-9]{2})\s+(?P[0-9/.]+)\s+(?P[0-9/.]+)') allegro_comment_mils = re.compile('Holesize (?P[0-9]{1,2})\. = (?P[0-9/.]+) Tolerance = \+(?P[0-9/.]+)/-(?P[0-9/.]+) (?P(PLATED)|(NON_PLATED)|(OPTIONAL)) MILS Quantity = [0-9]+') allegro2_comment_mils = re.compile('T(?P[0-9]{1,2}) Holesize (?P[0-9]{1,2})\. = (?P[0-9/.]+) Tolerance = \+(?P[0-9/.]+)/-(?P[0-9/.]+) (?P(PLATED)|(NON_PLATED)|(OPTIONAL)) MILS Quantity = [0-9]+') allegro_comment_mm = re.compile('Holesize (?P[0-9]{1,2})\. = (?P[0-9/.]+) Tolerance = \+(?P[0-9/.]+)/-(?P[0-9/.]+) (?P(PLATED)|(NON_PLATED)|(OPTIONAL)) MM Quantity = [0-9]+') allegro2_comment_mm = re.compile('T(?P[0-9]{1,2}) Holesize (?P[0-9]{1,2})\. = (?P[0-9/.]+) Tolerance = \+(?P[0-9/.]+)/-(?P[0-9/.]+) (?P(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/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 -- cgit From c70ece73eaef13b755ce117f7b580ecd2d45e604 Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Fri, 18 Nov 2016 07:56:51 -0500 Subject: Add support for square holes in basic primitives --- gerber/gerber_statements.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) (limited to 'gerber') 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): -- cgit From 6b672e98ff36b25e289c0d9e2ccc28337baa3c27 Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Fri, 18 Nov 2016 08:02:22 -0500 Subject: Add support for IF (Include File) rs274x command --- gerber/rs274x.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) (limited to 'gerber') diff --git a/gerber/rs274x.py b/gerber/rs274x.py index 5d64597..ff8addd 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"(?PAD)D(?P\d+)(?PP)[,](?P[^,%]*)" AD_MACRO = r"(?PAD)D(?P\d+)(?P{name})[,]?(?P[^,%]*)".format(name=NAME) AM = r"(?PAM)(?P{name})\*(?P[^%]*)".format(name=NAME) + # Include File + IF = r"(?PIF)(?P.*)" + # begin deprecated AS = r"(?PAS)(?P(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'(?PG3[67])\*') QUAD_MODE_STMT = re.compile(r'(?PG7[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": -- cgit From a7f1f6ef0fdd9c792b3234931754dac5d81b15e5 Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Fri, 18 Nov 2016 08:05:57 -0500 Subject: Finish adding square hole support, fix some primitive calculations, etc. --- gerber/primitives.py | 232 ++++++++++++++++++++++++++++++--------------------- gerber/rs274x.py | 115 ++++++++++++++++++------- gerber/utils.py | 7 +- 3 files changed, 222 insertions(+), 132 deletions(-) (limited to 'gerber') 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 "".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 "".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 '' % (self.diameter, self.position[0], self.position[1], self.hit) + return '' % (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/rs274x.py b/gerber/rs274x.py index ff8addd..5191fb7 100644 --- a/gerber/rs274x.py +++ b/gerber/rs274x.py @@ -514,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]) @@ -548,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) @@ -663,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": @@ -694,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]) @@ -724,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/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] -- cgit From 5696fc7064af674d02cf84cf7934c1ac7446259e Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Fri, 18 Nov 2016 08:09:03 -0500 Subject: Fix a bunch of bugs in rendering that showed up when rendering the gerbv test suite --- gerber/render/cairo_backend.py | 488 +++++++++++++++++++++++++---------------- 1 file changed, 305 insertions(+), 183 deletions(-) (limited to 'gerber') 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 @@ -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 -- cgit From 0ae5c48a65d59df8624a17c2b5a6aabff4c05e25 Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Fri, 18 Nov 2016 08:10:32 -0500 Subject: Fix rs274x output bugs --- gerber/render/rs274x_backend.py | 51 ++++++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 19 deletions(-) (limited to 'gerber') 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) -- cgit From 33e84943184d6643e92298825bd61441ee033a4f Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Fri, 18 Nov 2016 08:11:56 -0500 Subject: Add more tests for primitives --- gerber/tests/test_primitives.py | 99 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 87 insertions(+), 12 deletions(-) (limited to 'gerber') diff --git a/gerber/tests/test_primitives.py b/gerber/tests/test_primitives.py index 52d774c..97b335b 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 @@ -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) -- cgit From 389c273a8787a20f3e6ea5fdb951f62d7d5d4999 Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Fri, 18 Nov 2016 08:12:55 -0500 Subject: Clean up rs274x output tests --- gerber/tests/test_rs274x_backend.py | 38 ++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) (limited to 'gerber') 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 -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 -- cgit From e07ccc805fbaf05cff35e423d1559279bb2bc15e Mon Sep 17 00:00:00 2001 From: Hamilton Kibbe Date: Fri, 18 Nov 2016 08:14:26 -0500 Subject: Fix drill tests --- gerber/tests/test_primitives.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) (limited to 'gerber') diff --git a/gerber/tests/test_primitives.py b/gerber/tests/test_primitives.py index 97b335b..2fe5a4b 100644 --- a/gerber/tests/test_primitives.py +++ b/gerber/tests/test_primitives.py @@ -1270,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.) @@ -1279,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() -- cgit