summaryrefslogtreecommitdiff
path: root/gerbonara/gerber/excellon.py
diff options
context:
space:
mode:
authorjaseg <git@jaseg.de>2022-01-30 20:11:38 +0100
committerjaseg <git@jaseg.de>2022-01-30 20:11:38 +0100
commitc3ca4f95bd59f69d45e582a4149327f57a360760 (patch)
tree5f43c61a261698e2f671b5238a7aa9a71a0f6d23 /gerbonara/gerber/excellon.py
parent259a56186820923c78a5688f59bd8249cf958b5f (diff)
downloadgerbonara-c3ca4f95bd59f69d45e582a4149327f57a360760.tar.gz
gerbonara-c3ca4f95bd59f69d45e582a4149327f57a360760.tar.bz2
gerbonara-c3ca4f95bd59f69d45e582a4149327f57a360760.zip
Rename gerbonara/gerber package to just gerbonara
Diffstat (limited to 'gerbonara/gerber/excellon.py')
-rwxr-xr-xgerbonara/gerber/excellon.py887
1 files changed, 0 insertions, 887 deletions
diff --git a/gerbonara/gerber/excellon.py b/gerbonara/gerber/excellon.py
deleted file mode 100755
index 3382ffe..0000000
--- a/gerbonara/gerber/excellon.py
+++ /dev/null
@@ -1,887 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-
-# Copyright 2014 Hamilton Kibbe <ham@hamiltonkib.be>
-
-# 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.
-
-import math
-import operator
-import warnings
-import functools
-import dataclasses
-import re
-from enum import Enum
-from dataclasses import dataclass
-from collections import Counter
-from pathlib import Path
-
-from .cam import CamFile, FileSettings
-from .graphic_objects import Flash, Line, Arc
-from .apertures import ExcellonTool
-from .utils import Inch, MM, to_unit, InterpMode, RegexMatcher
-
-class ExcellonContext:
- def __init__(self, settings, tools):
- self.settings = settings
- self.tools = tools
- self.mode = None
- self.current_tool = None
- self.x, self.y = None, None
- self.drill_down = False
-
- def select_tool(self, tool):
- if self.current_tool != tool:
- if self.drill_down:
- yield 'M16' # drill up
- self.drill_down = False
-
- self.current_tool = tool
- yield f'T{self.tools[id(tool)]:02d}'
-
- def drill_mode(self):
- if self.mode != ProgramState.DRILLING:
- self.mode = ProgramState.DRILLING
- if self.drill_down:
- yield 'M16' # drill up
- self.drill_down = False
- yield 'G05' # drill mode
-
- def route_mode(self, unit, x, y):
- x, y = self.settings.unit(x, unit), self.settings.unit(y, unit)
-
- if self.mode == ProgramState.ROUTING and (self.x, self.y) == (x, y):
- return # nothing to do
-
- if self.drill_down:
- yield 'M16' # drill up
-
- # route mode
- yield 'G00' + 'X' + self.settings.write_excellon_value(x) + 'Y' + self.settings.write_excellon_value(y)
- yield 'M15' # drill down
- self.drill_down = True
- self.mode = ProgramState.ROUTING
- self.x, self.y = x, y
-
- def set_current_point(self, unit, x, y):
- self.x, self.y = self.settings.unit(x, unit), self.settings.unit(y, unit)
-
-def parse_allegro_ncparam(data, settings=None):
- # This function parses data from allegro's nc_param.txt and ncdrill.log files. We have to parse these files because
- # allegro Excellon files omit crucial information such as the *number format*. nc_param.txt really is the file we
- # want to parse, but sometimes due to user error it doesn't end up in the gerber package. In this case, we want to
- # still be able to extract the same information from the human-readable ncdrill.log.
-
- if settings is None:
- settings = FileSettings(number_format=(None, None), zeros='leading')
-
- lz_supp, tz_supp = False, False
- nf_int, nf_frac = settings.number_format
- for line in data.splitlines():
- line = re.sub(r'\s+', ' ', line.strip())
-
- if (match := re.fullmatch(r'FORMAT ([0-9]+\.[0-9]+)', line)):
- x, _, y = match[1].partition('.')
- nf_int, nf_frac = int(x), int(y)
-
- elif (match := re.fullmatch(r'INTEGER-PLACES ([0-9]+)', line)):
- nf_int = int(match[1])
-
- elif (match := re.fullmatch(r'DECIMAL-PLACES ([0-9]+)', line)):
- nf_frac = int(match[1])
-
- elif (match := re.fullmatch(r'COORDINATES (ABSOLUTE|.*)', line)):
- # I have not been able to find a single incremental-notation allegro file. Probably that is for the better.
- settings.notation = match[1].lower()
-
- elif (match := re.fullmatch(r'OUTPUT-UNITS (METRIC|ENGLISH|INCHES)', line)):
- # I have no idea wth is the difference between "ENGLISH" and "INCHES". I think one might just be the one
- # Allegro uses in footprint files, with the other one being used in gerber exports.
- settings.unit = MM if match[1] == 'METRIC' else Inch
-
- elif (match := re.fullmatch(r'SUPPRESS-LEAD-ZEROES (YES|NO)', line)):
- lz_supp = (match[1] == 'YES')
-
- elif (match := re.fullmatch(r'SUPPRESS-TRAIL-ZEROES (YES|NO)', line)):
- tz_supp = (match[1] == 'YES')
-
- if lz_supp and tz_supp:
- raise SyntaxError('Allegro Excellon parameters specify both leading and trailing zero suppression. We do not '
- 'know how to parse this. Please raise an issue on our issue tracker and provide an example file.')
-
- settings.number_format = nf_int, nf_frac
- settings.zeros = 'leading' if lz_supp else 'trailing'
- return settings
-
-
-def parse_allegro_logfile(data):
- found_tools = {}
- unit = None
-
- for line in data.splitlines():
- line = line.strip()
- line = re.sub(r'\s+', ' ', line)
-
- if (m := re.match(r'OUTPUT-UNITS (METRIC|ENGLISH|INCHES)', line)):
- # I have no idea wth is the difference between "ENGLISH" and "INCHES". I think one might just be the one
- # Allegro uses in footprint files, with the other one being used in gerber exports.
- unit = MM if m[1] == 'METRIC' else Inch
-
- elif (m := re.match(r'T(?P<index1>[0-9]+) (?P<index2>[0-9]+)\. (?P<diameter>[0-9/.]+) [0-9. /+-]* (?P<plated>PLATED|NON_PLATED|OPTIONAL) [0-9]+', line)):
- index1, index2 = int(m['index1']), int(m['index2'])
- if index1 != index2:
- return {}
-
- diameter = float(m['diameter'])
- if unit == Inch:
- diameter /= 1000
- is_plated = None if m['plated'] is None else (m['plated'] in ('PLATED', 'OPTIONAL'))
- found_tools[index1] = ExcellonTool(diameter=diameter, plated=is_plated, unit=unit)
- return found_tools
-
-class ExcellonFile(CamFile):
- def __init__(self, objects=None, comments=None, import_settings=None, original_path=None, generator_hints=None):
- super().__init__(original_path=original_path)
- self.objects = objects or []
- self.comments = comments or []
- self.import_settings = import_settings
- self.generator_hints = generator_hints or [] # This is a purely informational goodie from the parser. Use it as you wish.
-
- def __str__(self):
- name = f'{self.original_path.name} ' if self.original_path else ''
- if self.is_plated:
- plating = 'plated'
- elif self.is_nonplated:
- plating = 'nonplated'
- elif self.is_mixed_plating:
- plating = 'mixed plating'
- else:
- plating = 'unknown plating'
- return f'<ExcellonFile {name}{plating} with {len(list(self.drills()))} drills, {len(list(self.slots()))} slots using {len(self.drill_sizes())} tools>'
-
- def __repr__(self):
- return str(self)
-
- def __bool__(self):
- return not self.is_empty
-
- @property
- def is_plated(self):
- return all(obj.plated for obj in self.objects)
-
- @property
- def is_nonplated(self):
- return all(obj.plated == False for obj in self.objects) # False, not None
-
- @property
- def is_plating_unknown(self):
- return all(obj.plated is None for obj in self.objects) # False, not None
-
- @property
- def is_mixed_plating(self):
- return len({obj.plated for obj in self.objects}) > 1
-
- def append(self, obj_or_comment):
- if isinstnace(obj_or_comment, str):
- self.comments.append(obj_or_comment)
- else:
- self.objects.append(obj_or_comment)
-
- def to_gerber(self):
- apertures = {}
- out = GerberFile()
- out.comments = self.comments
-
- for obj in self.objects:
- if id(obj.tool) not in apertures:
- apertures[id(obj.tool)] = CircleAperture(obj.tool.diameter)
-
- out.objects.append(dataclasses.replace(obj, aperture=apertures[id(obj.tool)]))
-
- out.apertures = list(apertures.values())
-
- @property
- def generator(self):
- return self.generator_hints[0] if self.generator_hints else None
-
- def merge(self, other):
- if other is None:
- return
-
- self.objects += other.objects
- self.comments += other.comments
- self.generator_hints = None
- self.import_settings = None
-
- @classmethod
- def open(kls, filename, plated=None, settings=None):
- filename = Path(filename)
- logfile_tools = None
-
- # Parse allegro parameter files.
- # Prefer nc_param.txt over ncparam.log since the txt is the machine-readable one.
- if settings is None:
- for fn in 'nc_param.txt', 'ncdrill.log':
- if (param_file := filename.parent / fn).is_file():
- settings = parse_allegro_ncparam(param_file.read_text())
- warnings.warn(f'Loaded allegro-style excellon settings file {param_file}')
- break
-
- # TODO add try/except aronud this
- log_file = filename.parent / 'ncdrill.log'
- if log_file.is_file():
- logfile_tools = parse_allegro_logfile(log_file.read_text())
-
- return kls.from_string(filename.read_text(), settings=settings,
- filename=filename, plated=plated, logfile_tools=logfile_tools)
-
- @classmethod
- def from_string(kls, data, settings=None, filename=None, plated=None, logfile_tools=None):
- parser = ExcellonParser(settings, logfile_tools=logfile_tools)
- parser.do_parse(data, filename=filename)
- return kls(objects=parser.objects, comments=parser.comments, import_settings=settings,
- generator_hints=parser.generator_hints, original_path=filename)
-
- def _generate_statements(self, settings, drop_comments=True):
-
- yield '; XNC file generated by gerbonara'
- if self.comments and not drop_comments:
- yield '; Comments found in original file:'
- for comment in self.comments:
- yield ';' + comment
-
- yield 'M48'
- yield 'METRIC' if settings.unit == MM else 'INCH'
-
- # Build tool index
- tool_map = { id(obj.tool): obj.tool for obj in self.objects }
- tools = sorted(tool_map.items(), key=lambda id_tool: (id_tool[1].plated, id_tool[1].diameter, id_tool[1].depth_offset))
- tools = { tool_id: index for index, (tool_id, _tool) in enumerate(tools, start=1) }
- # FIXME dedup tools
-
- mixed_plating = (len({ tool.plated for tool in tool_map.values() }) > 1)
- if mixed_plating:
- warnings.warn('Multiple plating values in same file. Will use non-standard Altium comment syntax to indicate hole plating.')
-
- if tools and max(tools.values()) >= 100:
- warnings.warn('More than 99 tools defined. Some programs may not like three-digit tool indices.', SyntaxWarning)
-
- for tool_id, index in tools.items():
- tool = tool_map[tool_id]
- if mixed_plating:
- yield ';TYPE=PLATED' if tool.plated else ';TYPE=NON_PLATED'
- yield f'T{index:02d}' + tool.to_xnc(settings)
-
- yield '%'
-
- ctx = ExcellonContext(settings, tools)
-
- # Export objects
- for obj in self.objects:
- yield from obj.to_xnc(ctx)
-
- yield 'M30'
-
- def to_excellon(self, settings=None, drop_comments=True):
- ''' Export to Excellon format. This function always generates XNC, which is a well-defined subset of Excellon.
- '''
- if settings is None:
- if self.import_settings:
- settings = self.import_settings.copy()
- else:
- settings = FileSettings()
- settings.zeros = None
- settings.number_format = (3,5)
- return '\n'.join(self._generate_statements(settings, drop_comments=drop_comments))
-
- def save(self, filename, settings=None, drop_comments=True):
- with open(filename, 'w') as f:
- f.write(self.to_excellon(settings, drop_comments=drop_comments))
-
- def offset(self, x=0, y=0, unit=MM):
- self.objects = [ obj.with_offset(x, y, unit) for obj in self.objects ]
-
- def rotate(self, angle, cx=0, cy=0, unit=MM):
- if math.isclose(angle % (2*math.pi), 0):
- return
-
- for obj in self.objects:
- obj.rotate(angle, cx, cy, unit=unit)
-
- @property
- def has_mixed_plating(self):
- return len(set(obj.plated for obj in self.objects)) > 1
-
- @property
- def is_plated(self):
- return all(obj.plated for obj in self.objects)
-
- @property
- def is_nonplated(self):
- return not any(obj.plated for obj in self.objects)
-
- @property
- def is_empty(self):
- return not self.objects
-
- def __len__(self):
- return len(self.objects)
-
- def split_by_plating(self):
- plated = ExcellonFile(
- comments = self.comments.copy(),
- import_settings = self.import_settings.copy(),
- objects = [ obj for obj in self.objects if obj.plated ],
- filename = self.filename)
-
- nonplated = ExcellonFile(
- comments = self.comments.copy(),
- import_settings = self.import_settings.copy(),
- objects = [ obj for obj in self.objects if not obj.plated ],
- filename = self.filename)
-
- return nonplated, plated
-
- def path_lengths(self, unit):
- """ Calculate path lengths per tool.
-
- Returns: dict { tool: float(path length) }
-
- This function only sums actual cut lengths, and ignores travel lengths that the tool is doing without cutting to
- get from one object to another. Travel lengths depend on the CAM program's path planning, which highly depends
- on panelization and other factors. Additionally, an EDA tool will not even attempt to minimize travel distance
- as that's not its job.
- """
- lengths = {}
- tool = None
- for obj in sorted(self.objects, key=lambda obj: obj.tool):
- if tool != obj.tool:
- tool = obj.tool
- lengths[tool] = 0
-
- lengths[tool] += obj.curve_length(unit)
- return lengths
-
- def hit_count(self):
- return Counter(obj.tool for obj in self.objects)
-
- def drill_sizes(self):
- return sorted({ obj.tool.diameter for obj in self.objects })
-
- def drills(self):
- return (obj for obj in self.objects if isinstance(obj, Flash))
-
- def slots(self):
- return (obj for obj in self.objects if not isinstance(obj, Flash))
-
- @property
- def bounds(self):
- if not self.objects:
- return None
-
- (x_min, y_min), (x_max, y_max) = self.objects[0].bounding_box()
- for obj in self.objects:
- (obj_x_min, obj_y_min), (obj_x_max, obj_y_max) = self.objects[0].bounding_box()
- x_min, y_min = min(x_min, obj_x_min), min(y_min, obj_y_min)
- x_max, y_max = max(x_max, obj_x_max), max(y_max, obj_y_max)
-
- return ((x_min, y_min), (x_max, y_max))
-
-class ProgramState(Enum):
- HEADER = 0
- DRILLING = 1
- ROUTING = 2
- FINISHED = 3
-
-
-class ExcellonParser(object):
- def __init__(self, settings=None, logfile_tools=None):
- # NOTE XNC files do not contain an explicit number format specification, but all values have decimal points.
- # Thus, we set the default number format to (None, None). If the file does not contain an explicit specification
- # and FileSettings.parse_gerber_value encounters a number without an explicit decimal point, it will throw a
- # SyntaxError. In case of e.g. Allegro files where the number format and other options are specified separately
- # from the excellon file, the caller must pass in an already filled-out FileSettings object.
- if settings is None:
- self.settings = FileSettings(number_format=(None, None), zeros='leading')
- else:
- self.settings = settings
- self.program_state = None
- self.interpolation_mode = InterpMode.LINEAR
- self.tools = {}
- self.objects = []
- self.active_tool = None
- self.pos = 0, 0
- self.drill_down = False
- self.is_plated = None
- self.comments = []
- self.generator_hints = []
- self.lineno = None
- self.filename = None
- self.logfile_tools = logfile_tools or {}
-
- def warn(self, msg):
- warnings.warn(f'{self.filename}:{self.lineno} "{self.line}": {msg}', SyntaxWarning)
-
- def do_parse(self, data, filename=None):
- # filename arg is for error messages
- self.filename = filename = filename or '<unknown>'
-
- leftover = None
- for lineno, line in enumerate(data.splitlines(), start=1):
- line = line.strip()
- self.lineno, self.line = lineno, line # for warnings
-
- if not line:
- continue
-
- # Coordinates of G00 and G01 may be on the next line
- if line == 'G00' or line == 'G01':
- if leftover:
- self.warn('Two consecutive G00/G01 commands without coordinates. Ignoring first.')
- leftover = line
- continue
-
- if leftover:
- line = leftover + line
- leftover = None
-
- if line and self.program_state == ProgramState.FINISHED:
- self.warn('Commands found following end of program statement.')
- # TODO check first command in file is "start of header" command.
-
- try:
- print(f'{self.settings.number_format} {lineno} "{line}"')
- if not self.exprs.handle(self, line):
- raise ValueError('Unknown excellon statement:', line)
- except Exception as e:
- raise SyntaxError(f'{filename}:{lineno} "{line}": {e}') from e
-
- exprs = RegexMatcher()
-
- # NOTE: These must be kept before the generic comment handler at the end of this class so they match first.
- @exprs.match(r';T(?P<index1>[0-9]+) Holesize (?P<index2>[0-9]+)\. = (?P<diameter>[0-9/.]+) Tolerance = \+[0-9/.]+/-[0-9/.]+ (?P<plated>PLATED|NON_PLATED|OPTIONAL) (?P<unit>MILS|MM) Quantity = [0-9]+')
- def parse_allegro_tooldef(self, match):
- # NOTE: We ignore the given tolerances here since they are non-standard.
- self.program_state = ProgramState.HEADER # TODO is this needed? we need a test file.
- self.generator_hints.append('allegro')
-
- if (index := int(match['index1'])) != int(match['index2']): # index1 has leading zeros, index2 not.
- raise SyntaxError('BUG: Allegro excellon tool def has mismatching tool indices. Please file a bug report on our issue tracker and provide this file!')
-
- if index in self.tools:
- self.warn('Re-definition of tool index {index}, overwriting old definition.')
-
- # NOTE: We map "optionally" plated holes to plated holes for API simplicity. If you hit a case where that's a
- # problem, please raise an issue on our issue tracker, explain why you need this and provide an example file.
- is_plated = None if match['plated'] is None else (match['plated'] in ('PLATED', 'OPTIONAL'))
-
- diameter = float(match['diameter'])
-
- if match['unit'] == 'MILS':
- diameter /= 1000
- unit = Inch
- else:
- unit = MM
-
- if unit != self.settings.unit:
- self.warn('Allegro Excellon drill file tool definitions in {unit.name}, but file parameters say the '
- 'file should be in {settings.unit.name}. Please double-check that this is correct, and if it is, '
- 'please raise an issue on our issue tracker.')
-
- self.tools[index] = ExcellonTool(diameter=diameter, plated=is_plated, unit=unit)
-
- # Searching Github I found that EasyEDA has two different variants of the unit specification here.
- @exprs.match(';Holesize (?P<index>[0-9]+) = (?P<diameter>[.0-9]+) (?P<unit>INCH|inch|METRIC|mm)')
- def parse_easyeda_tooldef(self, match):
- unit = Inch if match['unit'].lower() == 'inch' else MM
- tool = ExcellonTool(diameter=float(match['diameter']), unit=unit, plated=self.is_plated)
-
- if (index := int(match['index'])) in self.tools:
- self.warn('Re-definition of tool index {index}, overwriting old definition.')
-
- self.tools[index] = tool
- self.generator_hints.append('easyeda')
-
- @exprs.match('T([0-9]+)(([A-Z][.0-9]+)+)') # Tool definition: T** with at least one parameter
- def parse_normal_tooldef(self, match):
- # We ignore parameters like feed rate or spindle speed that are not used for EDA -> CAM file transfer. This is
- # not a parser for the type of Excellon files a CAM program sends to the machine.
-
- if (index := int(match[1])) in self.tools:
- self.warn('Re-definition of tool index {index}, overwriting old definition.')
-
- params = { m[0]: self.settings.parse_gerber_value(m[1:]) for m in re.findall('[BCFHSTZ][.0-9]+', match[2]) }
-
- self.tools[index] = ExcellonTool(diameter=params.get('C'), depth_offset=params.get('Z'), plated=self.is_plated,
- unit=self.settings.unit)
-
- if set(params.keys()) == set('TFSC'):
- self.generator_hints.append('target3001') # target files look like altium files without the comments
-
- if len(self.tools) >= 3 and list(self.tools.keys()) == reversed(sorted(self.tools.keys())):
- self.generator_hints.append('geda')
-
- @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
- elif index not in self.tools:
- if not self.tools and index in self.logfile_tools:
- # allegro is just wonderful.
- self.warn(f'Undefined tool index {index} selected. We found an allegro drill log file next to this, so '
- 'we will use tool definitions from there.')
- self.active_tool = self.logfile_tools[index]
-
- else:
- raise SyntaxError(f'Undefined tool index {index} selected.')
-
- else:
- self.active_tool = self.tools[index]
-
- coord = lambda name, key=None: fr'({name}(?P<{key or name}>[+-]?[0-9]*\.?[0-9]*))?'
- xy_coord = coord('X') + coord('Y')
- xyaij_coord = xy_coord + coord('A') + coord('I') + coord('J')
-
- @exprs.match(r'R(?P<count>[0-9]+)' + xy_coord)
- def handle_repeat_hole(self, match):
- if self.program_state == ProgramState.HEADER:
- return
-
- dx = int(match['X'] or '0')
- dy = int(match['Y'] or '0')
-
- for i in range(int(match['count'])):
- self.pos = (self.pos[0] + dx, self.pos[1] + dy)
- # FIXME fix API below
- if not self.ensure_active_tool():
- return
-
- self.objects.append(Flash(*self.pos, self.active_tool, unit=self.settings.unit))
-
- def header_command(name):
- def wrap(fun):
- @functools.wraps(fun)
- def wrapper(self, *args, **kwargs):
- nonlocal name
- if self.program_state is None:
- self.warn(f'{name} header statement found before start of header')
- elif self.program_state != ProgramState.HEADER:
- self.warn(f'{name} header statement found after end of header')
- fun(self, *args, **kwargs)
- return wrapper
- return wrap
-
- @exprs.match('M48')
- def handle_begin_header(self, match):
- if self.program_state == ProgramState.HEADER:
- # It seems that only fritzing puts both a '%' start of header thingy and an M48 statement at the beginning
- # of the file.
- self.generator_hints.append('fritzing')
- elif self.program_state is not None:
- self.warn(f'M48 "header start" statement found in the middle of the file, currently in {self.program_state}')
- self.program_state = ProgramState.HEADER
-
- @exprs.match('M95')
- @header_command('M95')
- 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]
-
- else:
- self.warn('M00 statement found before first tool selection statement.')
-
- @exprs.match('M15')
- def handle_drill_down(self, match):
- self.drill_down = True
-
- @exprs.match('M16|M17')
- def handle_drill_up(self, match):
- self.drill_down = False
-
-
- @exprs.match('M30')
- def handle_end_of_program(self, match):
- if self.program_state in (None, ProgramState.HEADER):
- self.warn('M30 statement found before end of header.')
- self.program_state = ProgramState.FINISHED
- # ignore.
- # TODO: maybe add warning if this is followed by other commands.
-
- def do_move(self, match=None, x='X', y='Y'):
- x = self.settings.parse_gerber_value(match['X'])
- y = self.settings.parse_gerber_value(match['Y'])
-
- old_pos = self.pos
-
- if self.settings.absolute:
- if x is not None:
- self.pos = (x, self.pos[1])
- if y is not None:
- self.pos = (self.pos[0], y)
- else: # incremental
- if x is not None:
- self.pos = (self.pos[0]+x, self.pos[1])
- if y is not None:
- self.pos = (self.pos[0], self.pos[1]+y)
-
- return old_pos, self.pos
-
- @exprs.match('G00' + xy_coord)
- def handle_start_routing(self, match):
- if self.program_state is None:
- self.warn('Routing mode command found before header.')
- 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
-
- self.warn('Routing command found before first tool definition.')
- return None
-
- @exprs.match('(?P<mode>G01|G02|G03)' + xyaij_coord)
- def handle_linear_mode(self, match):
- if match['mode'] == 'G01':
- self.interpolation_mode = InterpMode.LINEAR
- else:
- clockwise = (match['mode'] == 'G02')
- self.interpolation_mode = InterpMode.CIRCULAR_CW if clockwise else InterpMode.CIRCULAR_CCW
-
- self.do_interpolation(match)
-
- def do_interpolation(self, match):
- x, y, a, i, j = match['X'], match['Y'], match['A'], match['I'], match['J']
-
- start, end = self.do_move(match)
-
- if self.program_state != ProgramState.ROUTING:
- return
-
- if not self.drill_down or not (match['X'] or match['Y']) or not self.ensure_active_tool():
- return
-
- if self.interpolation_mode == InterpMode.LINEAR:
- if a or i or j:
- self.warn('A/I/J arc coordinates found in linear mode.')
-
- self.objects.append(Line(*start, *end, self.active_tool, unit=self.settings.unit))
-
- else:
- if (x or y) and not (a or i or j):
- self.warn('Arc without radius found.')
-
- clockwise = (self.interpolation_mode == InterpMode.CIRCULAR_CW)
-
- if a: # radius given
- if i or j:
- self.warn('Arc without both radius and center specified.')
-
- # Convert endpoint-radius-endpoint notation to endpoint-center-endpoint notation. We always use the
- # smaller arc here.
- # from https://math.stackexchange.com/a/1781546
- r = settings.parse_gerber_value(a)
- x1, y1 = start
- x2, y2 = end
- dx, dy = (x2-x1)/2, (y2-y1)/2
- x0, y0 = x1+dx, y1+dy
- f = math.hypot(dx, dy) / math.sqrt(r**2 - a**2)
- if clockwise:
- cx = x0 + f*dy
- cy = y0 - f*dx
- else:
- cx = x0 - f*dy
- cy = y0 + f*dx
- i, j = cx-start[0], cy-start[1]
-
- else: # explicit center given
- i = settings.parse_gerber_value(i)
- j = settings.parse_gerber_value(j)
-
- self.objects.append(Arc(*start, *end, i, j, True, self.active_tool, unit=self.settings.unit))
-
- @exprs.match(r'(M71|METRIC|M72|INCH)(,LZ|,TZ)?(,0*\.0*)?')
- def parse_easyeda_format(self, match):
- metric = match[1] in ('METRIC', 'M71')
-
- self.settings.unit = MM if metric else Inch
-
- if match[2]:
- self.settings.zeros = 'trailing' if match[2] == ',LZ' else 'leading'
-
- # Newer EasyEDA exports have this in an altium-like FILE_FORMAT comment instead. Some files even have both.
- # This is used by newer autodesk eagles, fritzing and diptrace
- if match[3]:
- integer, _, fractional = match[3][1:].partition('.')
- self.settings.number_format = len(integer), len(fractional)
-
- elif self.settings.number_format == (None, None) and not metric:
- self.warn('Using implicit number format from bare "INCH" statement. This is normal for Fritzing, Diptrace, Geda and pcb-rnd.')
- self.settings.number_format = (2,4)
-
- @exprs.match('G90')
- @header_command('G90')
- def handle_absolute_mode(self, match):
- self.settings.notation = 'absolute'
-
- @exprs.match('G93' + xy_coord)
- def handle_absolute_mode(self, match):
- if int(match['X'] or 0) != 0 or int(match['Y'] or 0) != 0:
- # Siemens tooling likes to include a meaningless G93X0Y0 after its header.
- raise NotImplementedError('G93 zero set command is not supported.')
- self.generator_hints.append('siemens')
-
- @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(r'G40|G41|G42|F[0-9]+')
- def handle_unhandled(self, match):
- self.warn(f'{match[0]} excellon command intended for CAM tools found in EDA file.')
-
- @exprs.match(coord('X', 'x1') + coord('Y', 'y1') + 'G85' + coord('X', 'x2') + coord('Y', 'y2'))
- def handle_slot_dotted(self, match):
- self.warn('Weird G85 excellon slot command used. Please raise an issue on our issue tracker and provide this file for testing.')
- 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?
- if self.ensure_active_tool():
- # We ignore whether a slot is a "routed" G00/G01 slot or a "drilled" G85 slot and export both as routed
- # slots.
- self.objects.append(Line(*start, *end, self.active_tool, unit=self.settings.unit))
-
- @exprs.match(xyaij_coord)
- def handle_bare_coordinate(self, match):
- # Yes, drills in the header doesn't follow the specification, but it there are many files like this.
- if self.program_state in (ProgramState.DRILLING, ProgramState.HEADER):
- _start, end = self.do_move(match)
-
- if not self.ensure_active_tool():
- return
-
- self.objects.append(Flash(*end, self.active_tool, unit=self.settings.unit))
-
- elif self.program_state == ProgramState.ROUTING:
- # Bare coordinates for routing also seem illegal, but Siemens actually uses these.
- # Example file: siemens/80101_0125_F200_ContourPlated.ncd
- self.do_interpolation(match)
-
- else:
- self.warn('Bare coordinate after end of file')
-
- @exprs.match(r'; Format\s*: ([0-9]+\.[0-9]+) / (Absolute|Incremental) / (Inch|MM) / (Leading|Trailing)')
- def parse_siemens_format(self, match):
- x, _, y = match[1].partition('.')
- self.settings.number_format = int(x), int(y)
- # NOTE: Siemens files seem to always contain both this comment and an explicit METRIC/INC statement. However,
- # the meaning of "leading" and "trailing" is swapped in both: When this comment says leading, we get something
- # like "INCH,TZ".
- self.settings.notation = match[2].lower()
- self.settings.unit = to_unit(match[3])
- self.settings.zeros = {'Leading': 'trailing', 'Trailing': 'leading'}[match[4]]
- self.generator_hints.append('siemens')
-
- @exprs.match('; Contents: (Thru|.*) / (Drill|Mill) / (Plated|Non-Plated)')
- def parse_siemens_meta(self, match):
- self.is_plated = (match[3] == 'Plated')
- self.generator_hints.append('siemens')
-
- @exprs.match(';FILE_FORMAT=([0-9]:[0-9])')
- def parse_altium_easyeda_number_format_comment(self, match):
- # Altium or newer EasyEDA exports
- x, _, y = match[1].partition(':')
- self.settings.number_format = int(x), int(y)
-
- @exprs.match(';Layer: (.*)')
- def parse_easyeda_layer_name(self, match):
- # EasyEDA embeds the layer name in a comment. EasyEDA uses separate files for plated/non-plated. The (default?)
- # layer names are: "Drill PTH", "Drill NPTH"
- self.is_plated = 'NPTH' not in match[1]
- self.generator_hints.append('easyeda')
-
- @exprs.match(';TYPE=(PLATED|NON_PLATED)')
- def parse_altium_composite_plating_comment(self, match):
- # These can happen both before a tool definition and before a tool selection statement.
- # FIXME make sure we do the right thing in both cases.
- self.is_plated = (match[1] == 'PLATED')
-
- @exprs.match(';(Layer_Color=[-+0-9a-fA-F]*)')
- def parse_altium_layer_color(self, match):
- self.generator_hints.append('altium')
- self.comments.append(match[1])
-
- @exprs.match(';HEADER:')
- def parse_allegro_start_of_header(self, match):
- self.program_state = ProgramState.HEADER
- self.generator_hints.append('allegro')
-
- @exprs.match(r';GenerationSoftware,Autodesk,EAGLE,.*\*%')
- def parse_eagle_version_header(self, match):
- # NOTE: Only newer eagles export drills as XNC files. Older eagles produce an aperture-only gerber file called
- # "profile.gbr" instead.
- self.generator_hints.append('eagle')
-
- @exprs.match(';EasyEDA .*')
- def parse_easyeda_version_header(self, match):
- self.generator_hints.append('easyeda')
-
- @exprs.match(';DRILL .*KiCad .*')
- def parse_kicad_version_header(self, match):
- self.generator_hints.append('kicad')
-
- @exprs.match(';FORMAT={([-0-9]+:[-0-9]+) ?/ (.*) / (inch|.*) / decimal}')
- def parse_kicad_number_format_comment(self, match):
- x, _, y = match[1].partition(':')
- x = None if x == '-' else int(x)
- y = None if y == '-' else int(y)
- self.settings.number_format = x, y
- self.settings.notation = match[2]
- self.settings.unit = Inch if match[3] == 'inch' else MM
-
- @exprs.match(';(.*)')
- def parse_comment(self, match):
- self.comments.append(match[1].strip())
-
- if all(cmt.startswith(marker)
- for cmt, marker in zip(reversed(self.comments), ['Version', 'Job', 'User', 'Date'])):
- self.generator_hints.append('siemens')
-