diff options
-rw-r--r-- | gerbonara/gerber/cam.py | 8 | ||||
-rwxr-xr-x | gerbonara/gerber/excellon.py | 813 | ||||
-rw-r--r-- | gerbonara/gerber/excellon_statements.py | 106 | ||||
-rw-r--r-- | gerbonara/gerber/gerber_statements.py | 9 |
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 """ |