#!/usr/bin/env python # -*- coding: utf-8 -*- # Copyright 2014 Hamilton Kibbe # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # http://www.apache.org/licenses/LICENSE-2.0 # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """ Excellon File module ==================== **Excellon file classes** This module provides Excellon file classes and parsing utilities """ import math import operator from .cam import CamFile, FileSettings from .excellon_statements import * from .excellon_tool import ExcellonToolDefinitionParser from .primitives import Drill, Slot from .utils import inch, metric try: from cStringIO import StringIO except(ImportError): from io import StringIO def read(filename): """ Read data from filename and return an ExcellonFile Parameters ---------- filename : string Filename of file to parse Returns ------- file : :class:`gerber.excellon.ExcellonFile` An ExcellonFile created from the specified file. """ # File object should use settings from source file by default. with open(filename, 'rU') as f: data = f.read() settings = FileSettings(**detect_excellon_format(data)) return ExcellonParser(settings).parse(filename) def loads(data, filename=None, settings=None, tools=None): """ Read data from string and return an ExcellonFile Parameters ---------- data : string string containing Excellon file contents filename : string, optional string containing the filename of the data source tools: dict (optional) externally defined tools Returns ------- file : :class:`gerber.excellon.ExcellonFile` An ExcellonFile created from the specified file. """ # File object should use settings from source file by default. if not settings: settings = FileSettings(**detect_excellon_format(data)) return ExcellonParser(settings, tools).parse_raw(data, filename) class DrillHit(object): """Drill feature that is a single drill hole. Attributes ---------- tool : ExcellonTool Tool to drill the hole. Defines the size of the hole that is generated. position : tuple(float, float) Center position of the drill. """ def __init__(self, tool, position): 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 radius = self.tool.diameter / 2. min_x = position[0] - radius max_x = position[0] + radius min_y = position[1] - radius max_y = position[1] + radius return ((min_x, max_x), (min_y, max_y)) def offset(self, x_offset=0, y_offset=0): self.position = tuple(map(operator.add, self.position, (x_offset, y_offset))) def __str__(self): return 'Hit (%f, %f) {%s}' % (self.position[0], self.position[1], self.tool) class DrillSlot(object): """ A slot is created between two points. The way the slot is created depends on the statement used to create it """ TYPE_ROUT = 1 TYPE_G85 = 2 def __init__(self, tool, start, end, slot_type): self.tool = tool self.start = start 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 end = self.end radius = self.tool.diameter / 2. min_x = min(start[0], end[0]) - radius max_x = max(start[0], end[0]) + radius min_y = min(start[1], end[1]) - radius max_y = max(start[1], end[1]) + radius return ((min_x, max_x), (min_y, max_y)) def offset(self, x_offset=0, y_offset=0): self.start = tuple(map(operator.add, self.start, (x_offset, y_offset))) self.end = tuple(map(operator.add, self.end, (x_offset, y_offset))) class ExcellonFile(CamFile): """ A class representing a single excellon file The ExcellonFile class represents a single excellon file. http://www.excellon.com/manuals/program.htm (archived version at https://web.archive.org/web/20150920001043/http://www.excellon.com/manuals/program.htm) Parameters ---------- tools : list list of gerber file statements hits : list of tuples list of drill hits as (, (x, y)) settings : dict Dictionary of gerber file settings filename : string Filename of the source gerber file Attributes ---------- units : string either 'inch' or 'metric'. """ def __init__(self, statements, tools, hits, settings, filename=None): super(ExcellonFile, self).__init__(statements=statements, settings=settings, filename=filename) self.tools = tools self.hits = hits @property def primitives(self): """ Gets the primitives. Note that unlike Gerber, this generates new objects """ primitives = [] for hit in self.hits: if isinstance(hit, DrillHit): primitives.append(Drill(hit.position, hit.tool.diameter, units=self.settings.units)) elif isinstance(hit, DrillSlot): primitives.append(Slot(hit.start, hit.end, hit.tool.diameter, units=self.settings.units)) else: raise ValueError('Unknown hit type') return primitives @property def bounding_box(self): xmin = ymin = 100000000000 xmax = ymax = -100000000000 for hit in self.hits: bbox = hit.bounding_box xmin = min(bbox[0][0], xmin) xmax = max(bbox[0][1], xmax) ymin = min(bbox[1][0], ymin) ymax = max(bbox[1][1], ymax) return ((xmin, xmax), (ymin, ymax)) def report(self, filename=None): """ Print or save drill report """ if self.settings.units == 'inch': toolfmt = ' T{:0>2d} {:%d.%df} {: >3d} {:f}in.\n' % self.settings.format else: toolfmt = ' T{:0>2d} {:%d.%df} {: >3d} {:f}mm\n' % self.settings.format rprt = '=====================\nExcellon Drill Report\n=====================\n' if self.filename is not None: rprt += 'NC Drill File: %s\n\n' % self.filename rprt += 'Drill File Info:\n----------------\n' rprt += (' Data Mode %s\n' % 'Absolute' if self.settings.notation == 'absolute' else 'Incremental') rprt += (' Units %s\n' % 'Inches' if self.settings.units == 'inch' else 'Millimeters') rprt += '\nTool List:\n----------\n\n' rprt += ' Code Size Hits Path Length\n' rprt += ' --------------------------------------\n' for tool in iter(self.tools.values()): rprt += toolfmt.format(tool.number, tool.diameter, tool.hit_count, self.path_length(tool.number)) if filename is not None: with open(filename, 'w') as f: f.write(rprt) return rprt def write(self, filename=None): filename = filename if filename is not None else self.filename with open(filename, 'w') as f: # Copy the header verbatim for statement in self.statements: if not isinstance(statement, ToolSelectionStmt): f.write(statement.to_excellon(self.settings) + '\n') else: break # Write out coordinates for drill hits by tool for tool in iter(self.tools.values()): f.write(ToolSelectionStmt(tool.number).to_excellon(self.settings) + '\n') for hit in self.hits: if hit.tool.number == tool.number: f.write(CoordinateStmt( *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) for primitive in self.primitives: primitive.offset(x_offset, y_offset) for hit in self. hits: hit.offset(x_offset, y_offset) def path_length(self, tool_number=None): """ Return the path length for a given tool """ lengths = {} positions = {} for hit in self.hits: tool = hit.tool num = tool.number positions[num] = ((0, 0) if positions.get(num) is None else positions[num]) lengths[num] = 0.0 if lengths.get(num) is None else lengths[num] lengths[num] = lengths[ num] + math.hypot(*tuple(map(operator.sub, positions[num], hit.position))) positions[num] = hit.position if tool_number is None: return lengths else: return lengths.get(tool_number) def hit_count(self, tool_number=None): counts = {} for tool in iter(self.tools.values()): counts[tool.number] = tool.hit_count if tool_number is None: return counts else: return counts.get(tool_number) def update_tool(self, tool_number, **kwargs): """ Change parameters of a tool """ if kwargs.get('feed_rate') is not None: self.tools[tool_number].feed_rate = kwargs.get('feed_rate') if kwargs.get('retract_rate') is not None: self.tools[tool_number].retract_rate = kwargs.get('retract_rate') if kwargs.get('rpm') is not None: self.tools[tool_number].rpm = kwargs.get('rpm') if kwargs.get('diameter') is not None: self.tools[tool_number].diameter = kwargs.get('diameter') if kwargs.get('max_hit_count') is not None: self.tools[tool_number].max_hit_count = kwargs.get('max_hit_count') if kwargs.get('depth_offset') is not None: self.tools[tool_number].depth_offset = kwargs.get('depth_offset') # Update drill hits newtool = self.tools[tool_number] for hit in self.hits: if hit.tool.number == newtool.number: hit.tool = newtool 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, settings=None, ext_tools=None): self.notation = 'absolute' self.units = 'inch' self.zeros = 'leading' self.format = (2, 4) self.state = 'INIT' self.statements = [] self.tools = {} self.ext_tools = ext_tools or {} self.comment_tools = {} self.hits = [] self.active_tool = None 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 @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, 'rU') as f: data = f.read() return self.parse_raw(data, filename) def parse_raw(self, data, filename=None): for line in StringIO(data): 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) def _parse_line(self, line): # skip empty lines # Prepend previous line's data... line = '{}{}'.format(self._previous_line, line) self._previous_line = '' # Skip empty lines if not line.strip(): return 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') elif line[:3] == 'M95': self.statements.append(HeaderEndStmt()) if self.state == 'HEADER': self.state = 'DRILL' elif line[:3] == 'M15': self.statements.append(ZAxisRoutPositionStmt()) self.drill_down = True elif line[:3] == 'M16': self.statements.append(RetractWithClampingStmt()) self.drill_down = False elif line[:3] == 'M17': self.statements.append(RetractWithoutClampingStmt()) self.drill_down = False elif line[:3] == 'M30': stmt = EndOfProgramStmt.from_excellon(line, self._settings()) self.statements.append(stmt) elif line[:3] == 'G00': # Coordinates may be on the next line if line.strip() == 'G00': self._previous_line = line return self.statements.append(RouteModeStmt()) self.state = 'ROUT' stmt = CoordinateStmt.from_excellon(line[3:], self._settings()) stmt.mode = self.state 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 line[:3] == 'G01': # Coordinates might be on the next line... if line.strip() == 'G01': self._previous_line = line return self.statements.append(RouteModeStmt()) self.state = 'LINEAR' stmt = CoordinateStmt.from_excellon(line[3:], self._settings()) stmt.mode = self.state # The start position is where we were before the rout command 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 # 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) 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 if self.state == 'LINEAR' and self.drill_down: if not self.active_tool: self.active_tool = self._get_tool(1) self.hits.append(DrillSlot(self.active_tool, start, tuple(self.pos), DrillSlot.TYPE_ROUT)) 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) self.hits.append(DrillHit(self.active_tool, tuple(self.pos))) self.active_tool._hit() else: self.statements.append(UnknownStmt.from_excellon(line)) def _settings(self): return FileSettings(units=self.units, format=self.format, zeros=self.zeros, notation=self.notation) def _add_comment_tool(self, tool): """ Add a tool that was defined in the comments to this file. If we have already found this tool, then we will merge this comment tool definition into the information for the tool """ existing = self.tools.get(tool.number) if existing and existing.plated == None: existing.plated = tool.plated self.comment_tools[tool.number] = tool def _merge_properties(self, tool): """ When we have externally defined tools, merge the properties of that tool into this one For now, this is only plated """ if tool.plated == ExcellonTool.PLATED_UNKNOWN: ext_tool = self.ext_tools.get(tool.number) if ext_tool: tool.plated = ext_tool.plated def _get_tool(self, toolid): 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 return tool def detect_excellon_format(data=None, filename=None): """ Detect excellon file decimal format and zero-suppression settings. Parameters ---------- data : string String containing contents of Excellon file. 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 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, 'rU') as f: data = f.read() # 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