Added excellon format detection
This commit is contained in:
parent
76c03a55c9
commit
ae3bbff8b0
6 changed files with 231 additions and 32 deletions
|
|
@ -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):
|
||||
|
|
@ -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 (<int part>, <decimal part>)
|
||||
- `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
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
# Author: Hamilton Kibbe <ham@hamiltonkib.be>
|
||||
|
||||
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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue