summaryrefslogtreecommitdiff
path: root/gerbonara
diff options
context:
space:
mode:
authorjaseg <git@jaseg.de>2022-01-15 15:44:34 +0100
committerjaseg <git@jaseg.de>2022-01-15 15:44:34 +0100
commit69f360be7a71888ed64ca3677d5d06c2b3cfbb3c (patch)
tree2c658159ee3655facf7035bbd536be4574894624 /gerbonara
parent2ce0ff81ae50053022cc091b9db2d44f5b3ddc63 (diff)
downloadgerbonara-69f360be7a71888ed64ca3677d5d06c2b3cfbb3c.tar.gz
gerbonara-69f360be7a71888ed64ca3677d5d06c2b3cfbb3c.tar.bz2
gerbonara-69f360be7a71888ed64ca3677d5d06c2b3cfbb3c.zip
Excellon: WIP
Diffstat (limited to 'gerbonara')
-rw-r--r--gerbonara/gerber/cam.py8
-rwxr-xr-xgerbonara/gerber/excellon.py813
-rw-r--r--gerbonara/gerber/excellon_statements.py106
-rw-r--r--gerbonara/gerber/gerber_statements.py9
4 files changed, 331 insertions, 605 deletions
diff --git a/gerbonara/gerber/cam.py b/gerbonara/gerber/cam.py
index 7988b44..12906a0 100644
--- a/gerbonara/gerber/cam.py
+++ b/gerbonara/gerber/cam.py
@@ -62,6 +62,14 @@ class FileSettings:
def __str__(self):
return f'<File settings: unit={self.unit}/{self.angle_unit} notation={self.notation} zeros={self.zeros} number_format={self.number_format}>'
+ @property
+ def incremental(self):
+ return self.notation == 'incremental'
+
+ @property
+ def absolute(self):
+ return not self.incremental # default to absolute
+
def parse_gerber_value(self, value):
if not value:
return None
diff --git a/gerbonara/gerber/excellon.py b/gerbonara/gerber/excellon.py
index 27aaedd..550d783 100755
--- a/gerbonara/gerber/excellon.py
+++ b/gerbonara/gerber/excellon.py
@@ -25,6 +25,8 @@ This module provides Excellon file classes and parsing utilities
import math
import operator
+import warnings
+from enum import Enum
from .cam import CamFile, FileSettings
from .excellon_statements import *
@@ -99,16 +101,6 @@ class DrillHit(object):
self.tool = tool
self.position = position
- def to_inch(self):
- if self.tool.settings.units == 'metric':
- self.tool.to_inch()
- self.position = tuple(map(inch, self.position))
-
- def to_metric(self):
- if self.tool.settings.units == 'inch':
- self.tool.to_metric()
- self.position = tuple(map(metric, self.position))
-
@property
def bounding_box(self):
position = self.position
@@ -140,18 +132,6 @@ class DrillSlot(object):
self.end = end
self.slot_type = slot_type
- def to_inch(self):
- 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.settings.units == 'inch':
- self.tool.to_metric()
- self.start = tuple(map(metric, self.start))
- self.end = tuple(map(metric, self.end))
-
@property
def bounding_box(self):
start = self.start
@@ -279,37 +259,6 @@ class ExcellonFile(CamFile):
*hit.position).to_excellon(self.settings) + '\n')
f.write(EndOfProgramStmt().to_excellon() + '\n')
- def to_inch(self):
- """
- Convert units to inches
- """
- if 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()
- self.units = 'inch'
-
- def to_metric(self):
- """ Convert units to metric
- """
- if 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:
- # 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:
statement.offset(x_offset, y_offset)
@@ -368,37 +317,48 @@ class ExcellonFile(CamFile):
if hit.tool.number == newtool.number:
hit.tool = newtool
+class RegexMatcher:
+ def __init__(self):
+ self.mapping = {}
-class ExcellonParser(object):
- """ Excellon File Parser
+ def match(self, regex):
+ def wrapper(fun):
+ nonlocal self
+ self.mapping[regex] = fun
+ return fun
+ return wrapper
- Parameters
- ----------
- settings : FileSettings or dict-like
- Excellon file settings to use when interpreting the excellon file.
- """
- def __init__(self, settings=None, ext_tools=None):
- self.notation = 'absolute'
- self.units = 'inch'
- self.zeros = 'leading'
- self.format = (2, 4)
- self.state = 'INIT'
+ def handle(self, inst, line):
+ for regex, handler in self.mapping.items():
+ if (match := re.fullmatch(regex, line)):
+ handler(match)
+
+class ProgramState(Enum):
+ HEADER = 0
+ DRILLING = 1
+ ROUTING = 2
+ FINISHED = 2
+
+class InterpMode(Enum):
+ LINEAR = 0
+ CIRCULAR_CW = 1
+ CIRCULAR_CCW = 2
+
+
+class ExcellonParser(object):
+ def __init__(self):
+ self.settings = FileSettings(number_format=(2,4))
+ self.program_state = None
+ self.interpolation_mode = InterpMode.LINEAR
self.statements = []
self.tools = {}
- self.ext_tools = ext_tools or {}
self.comment_tools = {}
self.hits = []
self.active_tool = None
- self.pos = [0., 0.]
+ self.pos = 0, 0
self.drill_down = False
- self._previous_line = ''
- # Default for plated is None, which means we don't know
- self.plated = ExcellonTool.PLATED_UNKNOWN
- if settings is not None:
- self.units = settings.units
- self.zeros = settings.zeros
- self.notation = settings.notation
- self.format = settings.format
+ self.is_plated = None
+ self.feed_rate = None
@property
def coordinates(self):
@@ -435,470 +395,323 @@ class ExcellonParser(object):
self._parse_line(line.strip())
for stmt in self.statements:
stmt.units = self.units
- return ExcellonFile(self.statements, self.tools, self.hits,
- self._settings(), filename)
+ return ExcellonFile(self.statements, self.tools, self.hits, self.settings, filename)
- def _parse_line(self, line):
- # skip empty lines
- # Prepend previous line's data...
- line = '{}{}'.format(self._previous_line, line)
- self._previous_line = ''
+ def parse(self, filelike):
+ leftover = None
+ for line in filelike:
+ line = line.strip()
- # Skip empty lines
- if not line.strip():
- return
+ if not line:
+ continue
- if line[0] == ';':
- comment_stmt = CommentStmt.from_excellon(line)
- self.statements.append(comment_stmt)
-
- # get format from altium comment
- if "FILE_FORMAT" in comment_stmt.comment:
- detected_format = tuple(
- [int(x) for x in comment_stmt.comment.split('=')[1].split(":")])
- if detected_format:
- self.format = detected_format
-
- if "TYPE=PLATED" in comment_stmt.comment:
- self.plated = ExcellonTool.PLATED_YES
-
- if "TYPE=NON_PLATED" in comment_stmt.comment:
- self.plated = ExcellonTool.PLATED_NO
-
- if "HEADER:" in comment_stmt.comment:
- self.state = "HEADER"
-
- if " Holesize " in comment_stmt.comment:
- self.state = "HEADER"
-
- # Parse this as a hole definition
- tools = ExcellonToolDefinitionParser(self._settings()).parse_raw(comment_stmt.comment)
- if len(tools) == 1:
- tool = tools[tools.keys()[0]]
- self._add_comment_tool(tool)
-
- elif line[:3] == 'M48':
- self.statements.append(HeaderBeginStmt())
- self.state = 'HEADER'
-
- elif line[0] == '%':
- self.statements.append(RewindStopStmt())
- if self.state == 'HEADER':
- self.state = 'DRILL'
- elif self.state == 'INIT':
- self.state = 'HEADER'
-
- elif line[:3] == 'M00' and self.state == 'DRILL':
- if self.active_tool:
- cur_tool_number = self.active_tool.number
- next_tool = self._get_tool(cur_tool_number + 1)
-
- self.statements.append(NextToolSelectionStmt(self.active_tool, next_tool))
- self.active_tool = next_tool
- else:
- raise Exception('Invalid state exception')
+ # Coordinates of G00 and G01 may be on the next line
+ if line == 'G00' or line == 'G01':
+ if leftover:
+ warnings.warn('Two consecutive G00/G01 commands without coordinates. Ignoring first.', SyntaxWarning)
+ leftover = line
+ continue
- elif line[:3] == 'M95':
- self.statements.append(HeaderEndStmt())
- if self.state == 'HEADER':
- self.state = 'DRILL'
+ if leftover:
+ line = leftover + line
+ leftover = None
- elif line[:3] == 'M15':
- self.statements.append(ZAxisRoutPositionStmt())
- self.drill_down = True
+ if line and self.program_state == ProgramState.FINISHED:
+ warnings.warn('Commands found following end of program statement.', SyntaxWarning)
+ # TODO check first command in file is "start of header" command.
- elif line[:3] == 'M16':
- self.statements.append(RetractWithClampingStmt())
- self.drill_down = False
+ self.exprs.handle(self, line)
- elif line[:3] == 'M17':
- self.statements.append(RetractWithoutClampingStmt())
- self.drill_down = False
+ exprs = RegexMatcher()
- elif line[:3] == 'M30':
- stmt = EndOfProgramStmt.from_excellon(line, self._settings())
- self.statements.append(stmt)
+ @exprs.match(';(?P<comment>FILE_FORMAT=(?P<format>[0-9]:[0-9])|TYPE=(?P<plating>PLATED|NON_PLATED)|(?P<header>HEADER:)|.*(?P<tooldef> Holesize)|.*)')
+ def parse_comment(self, match):
- elif line[:3] == 'G00':
- # Coordinates may be on the next line
- if line.strip() == 'G00':
- self._previous_line = line
- return
+ # get format from altium comment
+ if (fmt := match['format']):
+ x, _, y = fmt.partition(':')
+ self.settings.number_format = int(x), int(y)
- self.statements.append(RouteModeStmt())
- self.state = 'ROUT'
+ elif (plating := match('plating']):
+ self.is_plated = (plating == 'PLATED')
- stmt = CoordinateStmt.from_excellon(line[3:], self._settings())
- stmt.mode = self.state
+ elif match['header']:
+ self.program_state = ProgramState.HEADER
- x = stmt.x
- y = stmt.y
- self.statements.append(stmt)
- if self.notation == 'absolute':
- if x is not None:
- self.pos[0] = x
- if y is not None:
- self.pos[1] = y
- else:
- if x is not None:
- self.pos[0] += x
- if y is not None:
- self.pos[1] += y
+ elif match['tooldef']:
+ self.program_state = ProgramState.HEADER
- elif line[:3] == 'G01':
+ # FIXME fix this code.
+ # Parse this as a hole definition
+ tools = ExcellonToolDefinitionParser(self.settings).parse_raw(comment_stmt.comment)
+ if len(tools) == 1:
+ tool = tools[tools.keys()[0]]
+ self._add_comment_tool(tool)
- # Coordinates might be on the next line...
- if line.strip() == 'G01':
- self._previous_line = line
- return
+ else:
+ target.comments.append(match['comment'].strip())
+
+ def header_command(fun):
+ @functools.wraps(fun)
+ def wrapper(*args, **kwargs):
+ if self.program_state is None:
+ warnings.warn('Header statement found before start of header')
+ elif self.program_state != ProgramState.HEADER:
+ warnings.warn('Header statement found after end of header')
+ fun(*args, **kwargs)
+ return wrapper
+
+ @exprs.match('M48')
+ def handle_begin_header(self, match):
+ if self.program_state is not None:
+ warnings.warn(f'M48 "header start" statement found in the middle of the file, currently in {self.program_state}', SyntaxWarning)
+ self.program_state = ProgramState.HEADER
+
+ @exprs.match('M95')
+ @header_command
+ def handle_end_header(self, match)
+ self.program_state = ProgramState.DRILLING
+
+ @exprs.match('M00')
+ def handle_next_tool(self, match):
+ #FIXME is this correct? Shouldn't this be "end of program"?
+ if self.active_tool:
+ self.active_tool = self.tools[self.tools.index(self.active_tool) + 1]
- self.statements.append(RouteModeStmt())
- self.state = 'LINEAR'
+ else:
+ warnings.warn('M00 statement found before first tool selection statement.', SyntaxWarning)
- stmt = CoordinateStmt.from_excellon(line[3:], self._settings())
- stmt.mode = self.state
+ @exprs.match('M15')
+ def handle_drill_down(self, match):
+ self.drill_down = True
- # The start position is where we were before the rout command
- start = (self.pos[0], self.pos[1])
+ @exprs.match('M16|M17')
+ def handle_drill_up(self, match):
+ self.drill_down = False
- x = stmt.x
- y = stmt.y
- self.statements.append(stmt)
- if self.notation == 'absolute':
- if x is not None:
- self.pos[0] = x
- if y is not None:
- self.pos[1] = y
- else:
- if x is not None:
- self.pos[0] += x
- if y is not None:
- self.pos[1] += y
-
- # Our ending position
- end = (self.pos[0], self.pos[1])
-
- if self.drill_down:
- if not self.active_tool:
- self.active_tool = self._get_tool(1)
-
- self.hits.append(DrillSlot(self.active_tool, start, end, DrillSlot.TYPE_ROUT))
- self.active_tool._hit()
-
- elif line[:3] == 'G05':
- self.statements.append(DrillModeStmt())
- self.drill_down = False
- self.state = 'DRILL'
-
- elif 'INCH' in line or 'METRIC' in line:
- stmt = UnitStmt.from_excellon(line)
- self.units = stmt.units
- self.zeros = stmt.zeros
- if stmt.format:
- self.format = stmt.format
- self.statements.append(stmt)
-
- elif line[:3] == 'M71' or line[:3] == 'M72':
- stmt = MeasuringModeStmt.from_excellon(line)
- self.units = stmt.units
- self.statements.append(stmt)
-
- elif line[:3] == 'ICI':
- stmt = IncrementalModeStmt.from_excellon(line)
- self.notation = 'incremental' if stmt.mode == 'on' else 'absolute'
- self.statements.append(stmt)
-
- elif line[:3] == 'VER':
- stmt = VersionStmt.from_excellon(line)
- self.statements.append(stmt)
-
- elif line[:4] == 'FMAT':
- stmt = FormatStmt.from_excellon(line)
- self.statements.append(stmt)
- self.format = stmt.format_tuple
-
- elif line[:3] == 'G40':
- self.statements.append(CutterCompensationOffStmt())
-
- elif line[:3] == 'G41':
- self.statements.append(CutterCompensationLeftStmt())
-
- elif line[:3] == 'G42':
- self.statements.append(CutterCompensationRightStmt())
-
- elif line[:3] == 'G90':
- self.statements.append(AbsoluteModeStmt())
- self.notation = 'absolute'
-
- elif line[0] == 'F':
- infeed_rate_stmt = ZAxisInfeedRateStmt.from_excellon(line)
- self.statements.append(infeed_rate_stmt)
-
- elif line[0] == 'T' and self.state == 'HEADER':
- if not ',OFF' in line and not ',ON' in line:
- tool = ExcellonTool.from_excellon(line, self._settings(), None, self.plated)
- self._merge_properties(tool)
- self.tools[tool.number] = tool
- self.statements.append(tool)
- else:
- self.statements.append(UnknownStmt.from_excellon(line))
-
- elif line[0] == 'T' and self.state != 'HEADER':
- stmt = ToolSelectionStmt.from_excellon(line)
- self.statements.append(stmt)
-
- # T0 is used as END marker, just ignore
- if stmt.tool != 0:
- tool = self._get_tool(stmt.tool)
-
- if not tool:
- # FIXME: for weird files with no tools defined, original calc from gerb
- if self._settings().units == "inch":
- diameter = (16 + 8 * stmt.tool) / 1000.0
- else:
- diameter = metric((16 + 8 * stmt.tool) / 1000.0)
-
- tool = ExcellonTool(
- self._settings(), number=stmt.tool, diameter=diameter)
- self.tools[tool.number] = tool
-
- # FIXME: need to add this tool definition inside header to
- # make sure it is properly written
- for i, s in enumerate(self.statements):
- if isinstance(s, ToolSelectionStmt) or isinstance(s, ExcellonTool):
- self.statements.insert(i, tool)
- break
-
- self.active_tool = tool
-
- elif line[0] == 'R' and self.state != 'HEADER':
- stmt = RepeatHoleStmt.from_excellon(line, self._settings())
- self.statements.append(stmt)
- for i in range(stmt.count):
- self.pos[0] += stmt.xdelta if stmt.xdelta is not None else 0
- self.pos[1] += stmt.ydelta if stmt.ydelta is not None else 0
- self.hits.append(DrillHit(self.active_tool, tuple(self.pos)))
- self.active_tool._hit()
-
- elif line[0] in ['X', 'Y']:
- if 'G85' in line:
- stmt = SlotStmt.from_excellon(line, self._settings())
-
- # I don't know if this is actually correct, but it makes sense
- # that this is where the tool would end
- x = stmt.x_end
- y = stmt.y_end
-
- self.statements.append(stmt)
-
- if self.notation == 'absolute':
- if x is not None:
- self.pos[0] = x
- if y is not None:
- self.pos[1] = y
- else:
- if x is not None:
- self.pos[0] += x
- if y is not None:
- self.pos[1] += y
- if self.state == 'DRILL' or self.state == 'HEADER':
- if not self.active_tool:
- self.active_tool = self._get_tool(1)
+ @exprs.match('M30')
+ def handle_end_of_program(self, match):
+ if self.program_state in (None, ProgramState.HEADER):
+ warnings.warn('M30 statement found before end of header.', SyntaxWarning)
+ self.program_state = FINISHED
+ # ignore.
+ # TODO: maybe add warning if this is followed by other commands.
- self.hits.append(DrillSlot(self.active_tool, (stmt.x_start, stmt.y_start), (stmt.x_end, stmt.y_end), DrillSlot.TYPE_G85))
- self.active_tool._hit()
- else:
- stmt = CoordinateStmt.from_excellon(line, self._settings())
-
- # We need this in case we are in rout mode
- start = (self.pos[0], self.pos[1])
-
- x = stmt.x
- y = stmt.y
- self.statements.append(stmt)
- if self.notation == 'absolute':
- if x is not None:
- self.pos[0] = x
- if y is not None:
- self.pos[1] = y
- else:
- if x is not None:
- self.pos[0] += x
- if y is not None:
- self.pos[1] += y
+ coord = lambda name, key=None: f'(?P<{key or name}>{name}[+-]?[0-9]*\.?[0-9]*)?'
+ xy_coord = coord('X') + coord('Y')
- if self.state == 'LINEAR' and self.drill_down:
- if not self.active_tool:
- self.active_tool = self._get_tool(1)
+ def do_move(self, match=None, x='X', y='Y'):
+ x = settings.parse_gerber_value(match['X'])
+ y = settings.parse_gerber_value(match['Y'])
- self.hits.append(DrillSlot(self.active_tool, start, tuple(self.pos), DrillSlot.TYPE_ROUT))
+ old_pos = self.pos
- elif self.state == 'DRILL' or self.state == 'HEADER':
- # Yes, drills in the header doesn't follow the specification, but it there are many
- # files like this
- if not self.active_tool:
- self.active_tool = self._get_tool(1)
+ if self.settings.absolute:
+ if x is not None:
+ self.pos[0] = x
+ if y is not None:
+ self.pos[1] = y
+ else: # incremental
+ if x is not None:
+ self.pos[0] += x
+ if y is not None:
+ self.pos[1] += y
+
+ return old_pos, new_pos
+
+ @exprs.match('G00' + xy_coord)
+ def handle_start_routing(self, match):
+ if self.program_state is None:
+ warnings.warn('Routing mode command found before header.', SyntaxWarning)
+ self.cutter_compensation = None
+ self.program_state = ProgramState.ROUTING
+ self.do_move(match)
+
+ @exprs.match('%')
+ def handle_rewind_shorthand(self, match):
+ if self.program_state is None:
+ self.program_state = ProgramState.HEADER
+ elif self.program_state is ProgramState.HEADER:
+ self.program_state = ProgramState.DRILLING
+ # FIXME handle rewind start
+
+ @exprs.match('G05')
+ def handle_drill_mode(self, match):
+ self.drill_down = False
+ self.program_state = ProgramState.DRILLING
+
+ def ensure_active_tool(self):
+ if self.active_tool:
+ return self.active_tool
+
+ if (self.active_tool := self.tools.get(1)):
+ return self.active_tool
- self.hits.append(DrillHit(self.active_tool, tuple(self.pos)))
- self.active_tool._hit()
+ warnings.warn('Routing command found before first tool definition.', SyntaxWarning)
+ return None
+
+ @exprs.match('(?P<mode>G01|G02|G03)' + xy_coord + aij_coord):
+ def handle_linear_mode(self, match)
+ x, y, a, i, j = match['x'], match['y'], match['a'], match['i'], match['j']
+
+ start, end = self.do_move(match)
+
+ if match['mode'] == 'G01':
+ self.interpolation_mode = InterpMode.LINEAR
+ if a or i or j:
+ warnings.warn('A/I/J arc coordinates found in linear mode.', SyntaxWarning)
else:
- self.statements.append(UnknownStmt.from_excellon(line))
+ self.interpolation_mode = InterpMode.CIRCULAR_CW if match['mode'] == 'G02' else InterpMode.CIRCULAR_CCW
+
+ if (x or y) and not (a or i or j):
+ warnings.warn('Arc without radius found.', SyntaxWarning)
- def _settings(self):
- return FileSettings(units=self.units, format=self.format,
- zeros=self.zeros, notation=self.notation)
+ if a and (i or j):
+ warnings.warn('Arc without both radius and center specified.', SyntaxWarning)
- def _add_comment_tool(self, tool):
- """
- Add a tool that was defined in the comments to this file.
+ if self.drill_down:
+ if not self.ensure_active_tool():
+ return
- If we have already found this tool, then we will merge this comment tool definition into
- the information for the tool
- """
+ # FIXME handle arcs
+ # FIXME fix the API below
+ self.hits.append(DrillSlot(self.active_tool, start, end, DrillSlot.TYPE_ROUT))
+ self.active_tool._hit()
+
+ @exprs.match('M71')
+ @header_command
+ def handle_metric_mode(self, match):
+ self.settings.unit = 'mm'
+
+ @exprs.match('M72')
+ @header_command
+ def handle_inch_mode(self, match):
+ self.settings.unit = 'inch'
+
+ @exprs.match('G90')
+ @header_command
+ def handle_absolute_mode(self, match):
+ self.settings.notation = 'absolute'
+
+ @exprs.match('ICI,?(ON|OFF)')
+ def handle_incremental_mode(self, match):
+ self.settings.notation = 'absolute' if match[1] == 'OFF' else 'incremental'
+
+ @exprs.match('(FMAT|VER),?([0-9]*)')
+ def handle_command_format(self, match):
+ # We do not support integer/fractional decimals specification via FMAT because that's stupid. If you need this,
+ # please raise an issue on our issue tracker, provide a sample file and tell us where on earth you found that
+ # file.
+ if match[2] not in ('', '2'):
+ raise SyntaxError(f'Unsupported FMAT format version {match["version"]}')
+
+ @exprs.match('G40')
+ def handle_cutter_comp_off(self, match):
+ self.cutter_compensation = None
+
+ @exprs.match('G41')
+ def handle_cutter_comp_off(self, match):
+ self.cutter_compensation = 'left'
+
+ @exprs.match('G42')
+ def handle_cutter_comp_off(self, match):
+ self.cutter_compensation = 'right'
+
+ @exprs.match(coord('F'))
+ def handle_feed_rate(self):
+ self.feed_rate = self.settings.parse_gerber_value(match['F'])
+
+ @exprs.match('T([0-9]+)(([A-Z][.0-9]+)+)') # Tool definition: T** with at least one parameter
+ def parse_tool_definition(self, match):
+ params = { m[0]: settings.parse_gerber_value(m[1:]) for m in re.findall('[BCFHSTZ][.0-9]+', match[2]) }
+ tool = ExcellonTool(
+ retract_rate = params.get('B'),
+ diameter = params.get('C'),
+ feed_rate = params.get('F'),
+ max_hit_count = params.get('H'),
+ rpm = 1000 * params.get('S'),
+ depth_offset = params.get('Z'),
+ plated = self.plated)
+
+ self.tools[int(match[1])] = tool
+
+ @exprs.match('T([0-9]+)')
+ def parse_tool_selection(self, match):
+ index = int(match[1])
+
+ if index == 0: # T0 is used as END marker, just ignore
+ return
+
+ if (tool := self.tools.get(index)):
+ self.active_tool = tool
+ return
- existing = self.tools.get(tool.number)
- if existing and existing.plated == None:
- existing.plated = tool.plated
+ # This is a nasty hack for weird files with no tools defined.
+ # Calculate tool radius from tool index.
+ dia = (16 + 8 * index) / 1000.0
+ if self.settings.unit == 'mm':
+ dia *= 25.4
- self.comment_tools[tool.number] = tool
+ # FIXME fix 'ExcellonTool' API below
+ self.tools[index] = ExcellonTool( self._settings(), number=stmt.tool, diameter=diameter)
- def _merge_properties(self, tool):
- """
- When we have externally defined tools, merge the properties of that tool into this one
+ @exprs.match(r'R(?P<count>[0-9]+)' + xy_coord).match(line)
+ def handle_repeat_hole(self, match):
+ if self.program_state == ProgramState.HEADER:
+ return
- For now, this is only plated
- """
+ dx = int(match['x'] or '0')
+ dy = int(match['y'] or '0')
- if tool.plated == ExcellonTool.PLATED_UNKNOWN:
- ext_tool = self.ext_tools.get(tool.number)
+ for i in range(int(match['count'])):
+ self.pos[0] += dx
+ self.pos[1] += dy
+ # FIXME fix API below
+ if not self.ensure_active_tool():
+ return
- if ext_tool:
- tool.plated = ext_tool.plated
+ self.hits.append(DrillHit(self.active_tool, tuple(self.pos)))
+ self.active_tool._hit()
+
+ @exprs.match(coord('X', 'x1') + coord('Y', 'y1') + 'G85' + coord('X', 'x2') + coord('Y', 'y2'))
+ def handle_slot_dotted(self, match):
+ self.do_move(match, 'X1', 'Y1')
+ start, end = self.do_move(match, 'X2', 'Y2')
+
+ if self.program_state in (ProgramState.DRILLING, ProgramState.HEADER): # FIXME should we realy handle this in header?
+ # FIXME fix API below
+ if not self.ensure_active_tool():
+ return
- def _get_tool(self, toolid):
+ self.hits.append(DrillSlot(self.active_tool, start, end, DrillSlot.TYPE_G85))
+ self.active_tool._hit()
- tool = self.tools.get(toolid)
- if not tool:
- tool = self.comment_tools.get(toolid)
- if tool:
- tool.settings = self._settings()
- self.tools[toolid] = tool
- if not tool:
- tool = self.ext_tools.get(toolid)
- if tool:
- tool.settings = self._settings()
- self.tools[toolid] = tool
+ @exprs.match(xy_coord)
+ def handle_naked_coordinate(self, match):
+ start, end = self.do_move(match)
- return tool
+ # FIXME handle arcs
-def detect_excellon_format(data=None, filename=None):
- """ Detect excellon file decimal format and zero-suppression settings.
+ # FIXME is this logic correct? Shouldn't we check program_state first, then interpolation_mode?
+ if self.interpolation_mode == InterpMode.LINEAR and self.drill_down:
+ # FIXME fix API below
+ if not self.ensure_active_tool():
+ return
- Parameters
- ----------
- data : string
- String containing contents of Excellon file.
+ self.hits.append(DrillSlot(self.active_tool, start, end, DrillSlot.TYPE_ROUT))
- 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
- zeros_options = ('leading', 'trailing', )
- format_options = ((2, 4), (2, 5), (3, 3),)
-
- if data is None and filename is None:
- raise ValueError('Either data or filename arguments must be provided')
- if data is None:
- with open(filename, 'r') as f:
- data = f.read()
+ # Yes, drills in the header doesn't follow the specification, but it there are many files like this
+ elif self.program_state in (ProgramState.DRILLING, ProgramState.HEADER):
+ # FIXME fix API below
+ if not self.ensure_active_tool():
+ return
+
+ self.hits.append(DrillHit(self.active_tool, end))
+ self.active_tool._hit()
+
+ else:
+ warnings.warn('Found unexpected coordinate', SyntaxWarning)
- # Check for obvious clues:
- p = ExcellonParser()
- p.parse_raw(data)
-
- # Get zero_suppression from a unit statement
- zero_statements = [stmt.zeros 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, 'zeros': detected_zeros}
-
- # Only look at remaining options
- if detected_format is not None:
- format_options = (detected_format,)
- if detected_zeros is not None:
- zeros_options = (detected_zeros,)
-
- # Brute force all remaining options, and pick the best looking one...
- for zeros in zeros_options:
- for fmt in format_options:
- key = (fmt, zeros)
- settings = FileSettings(zeros=zeros, format=fmt)
- try:
- p = ExcellonParser(settings)
- ef = p.parse_raw(data)
- size = tuple([t[0] - t[1] for t in ef.bounding_box])
- hole_area = 0.0
- for hit in p.hits:
- tool = hit.tool
- hole_area += math.pow(math.pi * tool.diameter / 2., 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 iter(results.keys()))
- zeros = set(key[1] for key in iter(results.keys()))
- 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, 'zeros': 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 iter(scores.keys()):
- if scores[key] == minscore:
- return {'format': key[0], 'zeros': 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]
- if board_area == 0:
- return 0
-
- hole_percentage = hole_area / board_area
- hole_score = (hole_percentage - 0.25) ** 2
- size_score = (board_area - 8) ** 2
- return hole_score * size_score
diff --git a/gerbonara/gerber/excellon_statements.py b/gerbonara/gerber/excellon_statements.py
index 38563a2..84e3bd3 100644
--- a/gerbonara/gerber/excellon_statements.py
+++ b/gerbonara/gerber/excellon_statements.py
@@ -24,6 +24,7 @@ Excellon Statements
import re
import uuid
import itertools
+from enum import Enum
from .utils import (decimal_string,
inch, metric)
@@ -41,35 +42,14 @@ __all__ = ['ExcellonTool', 'ToolSelectionStmt', 'CoordinateStmt',
'NextToolSelectionStmt', 'SlotStmt']
-class ExcellonStatement(object):
- """ Excellon Statement abstract base class
- """
-
- @classmethod
- def from_excellon(cls, line):
- raise NotImplementedError('from_excellon must be implemented in a '
- 'subclass')
-
- def __init__(self, unit='inch', id=None):
- self.units = unit
- self.id = uuid.uuid4().int if id is None else id
-
- def to_excellon(self, settings=None):
- raise NotImplementedError('to_excellon must be implemented in a '
- 'subclass')
-
- def to_inch(self):
- self.units = 'inch'
-
- def to_metric(self):
- self.units = 'metric'
-
- def offset(self, x_offset=0, y_offset=0):
- pass
-
- def __eq__(self, other):
- return self.__dict__ == other.__dict__
+class Plating(Enum):
+ UNKNOWN = 0
+ NONPLATED = 1
+ PLATED = 2
+ OPTIONAL = 3
+class ExcellonStatement:
+ pass
class ExcellonTool(ExcellonStatement):
""" Excellon Tool class
@@ -115,67 +95,6 @@ class ExcellonTool(ExcellonStatement):
Number of tool hits in excellon file.
"""
- PLATED_UNKNOWN = None
- PLATED_YES = 'plated'
- PLATED_NO = 'nonplated'
- PLATED_OPTIONAL = 'optional'
-
- @classmethod
- def from_tool(cls, tool):
- args = {}
-
- args['depth_offset'] = tool.depth_offset
- args['diameter'] = tool.diameter
- args['feed_rate'] = tool.feed_rate
- args['max_hit_count'] = tool.max_hit_count
- args['number'] = tool.number
- args['plated'] = tool.plated
- args['retract_rate'] = tool.retract_rate
- args['rpm'] = tool.rpm
-
- return cls(None, **args)
-
- @classmethod
- def from_excellon(cls, line, settings, id=None, plated=None):
- """ Create a Tool from an excellon file tool definition line.
-
- Parameters
- ----------
- line : string
- Tool definition line from an excellon file.
-
- settings : FileSettings (dict-like)
- Excellon file-wide settings
-
- Returns
- -------
- tool : Tool
- An ExcellonTool representing the tool defined in `line`
- """
- commands = pairwise(re.split('([BCFHSTZ])', line)[1:])
- args = {}
- args['id'] = id
- for cmd, val in commands:
- if cmd == 'B':
- args['retract_rate'] = settings.parse_gerber_value(val)
- elif cmd == 'C':
- args['diameter'] = settings.parse_gerber_value(val)
- elif cmd == 'F':
- args['feed_rate'] = settings.parse_gerber_value(val)
- elif cmd == 'H':
- args['max_hit_count'] = settings.parse_gerber_value(val)
- elif cmd == 'S':
- args['rpm'] = 1000 * settings.parse_gerber_value(val)
- elif cmd == 'T':
- args['number'] = int(val)
- elif cmd == 'Z':
- args['depth_offset'] = settings.parse_gerber_value(val)
-
- if plated != ExcellonTool.PLATED_UNKNOWN:
- # Sometimees we can can parse the plating status
- args['plated'] = plated
- return cls(settings, **args)
-
@classmethod
def from_dict(cls, settings, tool_dict):
""" Create an ExcellonTool from a dict.
@@ -491,16 +410,11 @@ class RepeatHoleStmt(ExcellonStatement):
class CommentStmt(ExcellonStatement):
- @classmethod
- def from_excellon(cls, line, **kwargs):
- return cls(line.lstrip(';'))
-
- def __init__(self, comment, **kwargs):
- super(CommentStmt, self).__init__(**kwargs)
+ def __init__(self, comment):
self.comment = comment
def to_excellon(self, settings=None):
- return ';%s' % self.comment
+ return ';' + self.comment
class HeaderBeginStmt(ExcellonStatement):
diff --git a/gerbonara/gerber/gerber_statements.py b/gerbonara/gerber/gerber_statements.py
index 4e46475..2291160 100644
--- a/gerbonara/gerber/gerber_statements.py
+++ b/gerbonara/gerber/gerber_statements.py
@@ -32,12 +32,6 @@ def convert(value, src, dst):
class Statement:
pass
- def update_graphics_state(self, _state):
- pass
-
- def render_primitives(self, _state):
- pass
-
class ParamStmt(Statement):
pass
@@ -79,9 +73,6 @@ class LoadPolarityStmt(ParamStmt):
lp = 'dark' if self.dark else 'clear'
return f'<LP Level Polarity: {lp}>'
- def update_graphics_state(self, state):
- state.polarity_dark = self.dark
-
class ApertureDefStmt(ParamStmt):
""" AD - Aperture Definition Statement """