diff --git a/gerber/cnc.py b/gerber/cam.py similarity index 92% rename from gerber/cnc.py rename to gerber/cam.py index d17517a..e7a49d1 100644 --- a/gerber/cnc.py +++ b/gerber/cam.py @@ -15,16 +15,16 @@ # See the License for the specific language governing permissions and # limitations under the License. """ -gerber.cnc +CAM File ============ -**CNC file classes** +**AM file classes** This module provides common base classes for Excellon/Gerber CNC files """ class FileSettings(object): - """ CNC File Settings + """ CAM File Settings Provides a common representation of gerber/excellon file settings """ @@ -60,7 +60,7 @@ class FileSettings(object): raise KeyError() -class CncFile(object): +class CamFile(object): """ Base class for Gerber/Excellon files. Provides a common set of settings parameters. @@ -71,7 +71,10 @@ class CncFile(object): The current file configuration. filename : string - Name of the file that this CncFile represents. + Name of the file that this CamFile represents. + + layer_name : string + Name of the PCB layer that the file represents Attributes ---------- @@ -92,7 +95,8 @@ class CncFile(object): decimal digits) """ - def __init__(self, statements=None, settings=None, filename=None): + def __init__(self, statements=None, settings=None, filename=None, + layer_name=None): if settings is not None: self.notation = settings['notation'] self.units = settings['units'] @@ -105,6 +109,7 @@ class CncFile(object): self.format = (2, 5) self.statements = statements if statements is not None else [] self.filename = filename + self.layer_name = layer_name @property def settings(self): diff --git a/gerber/excellon.py b/gerber/excellon.py index 4166de6..1a498dc 100755 --- a/gerber/excellon.py +++ b/gerber/excellon.py @@ -24,16 +24,22 @@ This module provides Excellon file classes and parsing utilities from .excellon_statements import * -from .cnc import CncFile, FileSettings +from .cam import CamFile, FileSettings +import math def read(filename): """ Read data from filename and return an ExcellonFile """ - return ExcellonParser().parse(filename) + detected_settings = detect_excellon_format(filename) + settings = FileSettings(**detected_settings) + zeros = '' + print('Detected %d:%d format with %s zero suppression' % + (settings.format[0], settings.format[1], settings.zero_suppression)) + return ExcellonParser(settings).parse(filename) -class ExcellonFile(CncFile): +class ExcellonFile(CamFile): """ A class representing a single excellon file The ExcellonFile class represents a single excellon file. @@ -83,8 +89,13 @@ class ExcellonFile(CncFile): class ExcellonParser(object): """ Excellon File Parser + + Parameters + ---------- + settings : FileSettings or dict-like + Excellon file settings to use when interpreting the excellon file. """ - def __init__(self): + def __init__(self, settings=None): self.notation = 'absolute' self.units = 'inch' self.zero_suppression = 'trailing' @@ -95,7 +106,38 @@ class ExcellonParser(object): self.hits = [] self.active_tool = None self.pos = [0., 0.] + if settings is not None: + self.units = settings['units'] + self.zero_suppression = settings['zero_suppression'] + self.notation = settings['notation'] + self.format = settings['format'] + + @property + def coordinates(self): + return [(stmt.x, stmt.y) for stmt in self.statements if isinstance(stmt, CoordinateStmt)] + + @property + def bounds(self): + xmin = ymin = 100000000000 + xmax = ymax = -100000000000 + for x, y in self.coordinates: + if x is not None: + xmin = x if x < xmin else xmin + xmax = x if x > xmax else xmax + if y is not None: + ymin = y if y < ymin else ymin + ymax = y if y > ymax else ymax + return ((xmin, xmax), (ymin, ymax)) + + @property + def hole_sizes(self): + return [stmt.diameter for stmt in self.statements if isinstance(stmt, ExcellonTool)] + + @property + def hole_count(self): + return len(self.hits) + def parse(self, filename): with open(filename, 'r') as f: for line in f: @@ -194,3 +236,106 @@ class ExcellonParser(object): return FileSettings(units=self.units, format=self.format, zero_suppression=self.zero_suppression, notation=self.notation) + + +def detect_excellon_format(filename): + """ Detect excellon file decimal format and zero-suppression settings. + + Parameters + ---------- + filename : string + Name of the file to parse. This does not check if the file is actually + an Excellon file, so do that before calling this. + + Returns + ------- + settings : dict + Detected excellon file settings. Keys are + - `format`: decimal format as tuple (, ) + - `zero_suppression`: zero suppression, 'leading' or 'trailing' + """ + results = {} + detected_zeros = None + detected_format = None + zs_options = ('leading', 'trailing', ) + format_options = ((2, 4), (2, 5), (3, 3),) + + # Check for obvious clues: + p = ExcellonParser() + p.parse(filename) + + # Get zero_suppression from a unit statement + zero_statements = [stmt.zero_suppression for stmt in p.statements + if isinstance(stmt, UnitStmt)] + + # get format from altium comment + format_comment = [stmt.comment for stmt in p.statements + if isinstance(stmt, CommentStmt) + and 'FILE_FORMAT' in stmt.comment] + + detected_format = (tuple([int(val) for val in + format_comment[0].split('=')[1].split(':')]) + if len(format_comment) == 1 else None) + detected_zeros = zero_statements[0] if len(zero_statements) == 1 else None + + # Bail out here if possible + if detected_format is not None and detected_zeros is not None: + return {'format': detected_format, 'zero_suppression': detected_zeros} + + # Only look at remaining options + if detected_format is not None: + format_options = (detected_format,) + if detected_zeros is not None: + zs_options = (detected_zeros,) + + # Brute force all remaining options, and pick the best looking one... + for zs in zs_options: + for fmt in format_options: + key = (fmt, zs) + settings = FileSettings(zero_suppression=zs, format=fmt) + try: + p = ExcellonParser(settings) + p.parse(filename) + size = tuple([t[1] - t[0] for t in p.bounds]) + hole_area = 0.0 + for hit in p.hits: + tool = hit[0] + hole_area += math.pow(math.pi * tool.diameter, 2) + results[key] = (size, p.hole_count, hole_area) + except: + pass + + # See if any of the dimensions are left with only a single option + formats = set(key[0] for key in results.iterkeys()) + zeros = set(key[1] for key in results.iterkeys()) + if len(formats) == 1: + detected_format = formats.pop() + if len(zeros) == 1: + detected_zeros = zeros.pop() + + # Bail out here if we got everything.... + if detected_format is not None and detected_zeros is not None: + return {'format': detected_format, 'zero_suppression': detected_zeros} + + # Otherwise score each option and pick the best candidate + else: + scores = {} + for key in results.keys(): + size, count, diameter = results[key] + scores[key] = _layer_size_score(size, count, diameter) + minscore = min(scores.values()) + for key in scores.iterkeys(): + if scores[key] == minscore: + return {'format': key[0], 'zero_suppression': key[1]} + + +def _layer_size_score(size, hole_count, hole_area): + """ Heuristic used for determining the correct file number interpretation. + Lower is better. + """ + board_area = size[0] * size[1] + hole_percentage = hole_area / board_area + hole_score = (hole_percentage - 0.25) ** 2 + size_score = (board_area - 8) **2 + return hole_score * size_score + \ No newline at end of file diff --git a/gerber/gerber.py b/gerber/gerber.py index 4ce261d..215b970 100644 --- a/gerber/gerber.py +++ b/gerber/gerber.py @@ -27,7 +27,7 @@ This module provides an RS-274-X class and parser import re import json from .gerber_statements import * -from .cnc import CncFile, FileSettings +from .cam import CamFile, FileSettings @@ -38,7 +38,7 @@ def read(filename): return GerberParser().parse(filename) -class GerberFile(CncFile): +class GerberFile(CamFile): """ A class representing a single gerber file The GerberFile class represents a single gerber file. diff --git a/gerber/gerber_statements.py b/gerber/gerber_statements.py index a22eae2..218074f 100644 --- a/gerber/gerber_statements.py +++ b/gerber/gerber_statements.py @@ -133,7 +133,12 @@ class MOParamStmt(ParamStmt): @classmethod def from_dict(cls, stmt_dict): param = stmt_dict.get('param') - mo = 'inch' if stmt_dict.get('mo') == 'IN' else 'metric' + if stmt_dict.get('mo').lower() == 'in': + mo = 'inch' + elif stmt_dict.get('mo').lower() == 'mm': + mo = 'metric' + else: + mo = None return cls(param, mo) def __init__(self, param, mo): diff --git a/gerber/tests/test_cnc.py b/gerber/tests/test_cam.py similarity index 93% rename from gerber/tests/test_cnc.py rename to gerber/tests/test_cam.py index ace047e..4af1984 100644 --- a/gerber/tests/test_cnc.py +++ b/gerber/tests/test_cam.py @@ -3,7 +3,7 @@ # Author: Hamilton Kibbe -from ..cnc import CncFile, FileSettings +from ..cam import CamFile, FileSettings from tests import * @@ -46,5 +46,5 @@ def test_filesettings_assign(): assert_equal(fs.zero_suppression, 'test') assert_equal(fs.format, 'test') - def test_smoke_cncfile(): - pass +def test_smoke_camfile(): + cf = CamFile diff --git a/gerber/tests/test_gerber_statements.py b/gerber/tests/test_gerber_statements.py index 9e73fd4..a463c9d 100644 --- a/gerber/tests/test_gerber_statements.py +++ b/gerber/tests/test_gerber_statements.py @@ -8,7 +8,7 @@ from ..gerber_statements import * def test_FSParamStmt_factory(): - """ Test FSParamStruct factory correctly handles parameters + """ Test FSParamStruct factory """ stmt = {'param': 'FS', 'zero': 'L', 'notation': 'A', 'x': '27'} fs = FSParamStmt.from_dict(stmt) @@ -24,6 +24,18 @@ def test_FSParamStmt_factory(): assert_equal(fs.notation, 'incremental') assert_equal(fs.format, (2, 7)) +def test_FSParamStmt(): + """ Test FSParamStmt initialization + """ + param = 'FS' + zeros = 'trailing' + notation = 'absolute' + fmt = (2, 5) + stmt = FSParamStmt(param, zeros, notation, fmt) + assert_equal(stmt.param, param) + assert_equal(stmt.zero_suppression, zeros) + assert_equal(stmt.notation, notation) + assert_equal(stmt.format, fmt) def test_FSParamStmt_dump(): """ Test FSParamStmt to_gerber() @@ -38,17 +50,31 @@ def test_FSParamStmt_dump(): def test_MOParamStmt_factory(): - """ Test MOParamStruct factory correctly handles parameters + """ Test MOParamStruct factory """ - stmt = {'param': 'MO', 'mo': 'IN'} - mo = MOParamStmt.from_dict(stmt) - assert_equal(mo.param, 'MO') - assert_equal(mo.mode, 'inch') + stmts = [{'param': 'MO', 'mo': 'IN'}, {'param': 'MO', 'mo': 'in'}, ] + for stmt in stmts: + mo = MOParamStmt.from_dict(stmt) + assert_equal(mo.param, 'MO') + assert_equal(mo.mode, 'inch') - stmt = {'param': 'MO', 'mo': 'MM'} - mo = MOParamStmt.from_dict(stmt) - assert_equal(mo.param, 'MO') - assert_equal(mo.mode, 'metric') + stmts = [{'param': 'MO', 'mo': 'MM'}, {'param': 'MO', 'mo': 'mm'}, ] + for stmt in stmts: + mo = MOParamStmt.from_dict(stmt) + assert_equal(mo.param, 'MO') + assert_equal(mo.mode, 'metric') + +def test_MOParamStmt(): + """ Test MOParamStmt initialization + """ + param = 'MO' + mode = 'inch' + stmt = MOParamStmt(param, mode) + assert_equal(stmt.param, param) + + for mode in ['inch', 'metric']: + stmt = MOParamStmt(param, mode) + assert_equal(stmt.mode, mode) def test_MOParamStmt_dump(): @@ -64,7 +90,7 @@ def test_MOParamStmt_dump(): def test_IPParamStmt_factory(): - """ Test IPParamStruct factory correctly handles parameters + """ Test IPParamStruct factory """ stmt = {'param': 'IP', 'ip': 'POS'} ip = IPParamStmt.from_dict(stmt) @@ -74,6 +100,15 @@ def test_IPParamStmt_factory(): ip = IPParamStmt.from_dict(stmt) assert_equal(ip.ip, 'negative') +def test_IPParamStmt(): + """ Test IPParamStmt initialization + """ + param = 'IP' + for ip in ['positive', 'negative']: + stmt = IPParamStmt(param, ip) + assert_equal(stmt.param, param) + assert_equal(stmt.ip, ip) + def test_IPParamStmt_dump(): """ Test IPParamStmt to_gerber() @@ -88,14 +123,23 @@ def test_IPParamStmt_dump(): def test_OFParamStmt_factory(): - """ Test OFParamStmt factory correctly handles parameters + """ Test OFParamStmt factory """ stmt = {'param': 'OF', 'a': '0.1234567', 'b': '0.1234567'} of = OFParamStmt.from_dict(stmt) assert_equal(of.a, 0.1234567) assert_equal(of.b, 0.1234567) - +def test_OFParamStmt(): + """ Test IPParamStmt initialization + """ + param = 'OF' + for val in [0.0, -3.4567]: + stmt = OFParamStmt(param, val, val) + assert_equal(stmt.param, param) + assert_equal(stmt.a, val) + assert_equal(stmt.b, val) + def test_OFParamStmt_dump(): """ Test OFParamStmt to_gerber() """ @@ -105,7 +149,7 @@ def test_OFParamStmt_dump(): def test_LPParamStmt_factory(): - """ Test LPParamStmt factory correctly handles parameters + """ Test LPParamStmt factory """ stmt = {'param': 'LP', 'lp': 'C'} lp = LPParamStmt.from_dict(stmt) @@ -128,7 +172,7 @@ def test_LPParamStmt_dump(): def test_INParamStmt_factory(): - """ Test INParamStmt factory correctly handles parameters + """ Test INParamStmt factory """ stmt = {'param': 'IN', 'name': 'test'} inp = INParamStmt.from_dict(stmt) @@ -143,7 +187,7 @@ def test_INParamStmt_dump(): def test_LNParamStmt_factory(): - """ Test LNParamStmt factory correctly handles parameters + """ Test LNParamStmt factory """ stmt = {'param': 'LN', 'name': 'test'} lnp = LNParamStmt.from_dict(stmt)