From 336a18fb493c79824323a59865083a0037a4a2f4 Mon Sep 17 00:00:00 2001 From: jaseg Date: Sun, 16 Jan 2022 21:59:24 +0100 Subject: Excellon WIP --- gerbonara/gerber/apertures.py | 132 +++-- gerbonara/gerber/cam.py | 5 +- gerbonara/gerber/excellon.py | 648 +++++++++--------------- gerbonara/gerber/excellon_statements.py | 871 -------------------------------- gerbonara/gerber/excellon_tool.py | 190 ------- gerbonara/gerber/gerber_statements.py | 2 + gerbonara/gerber/graphic_objects.py | 123 +++-- gerbonara/gerber/graphic_primitives.py | 1 + gerbonara/gerber/rs274x.py | 3 +- gerbonara/gerber/utils.py | 42 ++ 10 files changed, 464 insertions(+), 1553 deletions(-) delete mode 100644 gerbonara/gerber/excellon_statements.py delete mode 100644 gerbonara/gerber/excellon_tool.py (limited to 'gerbonara/gerber') diff --git a/gerbonara/gerber/apertures.py b/gerbonara/gerber/apertures.py index b18b7a1..e362e0d 100644 --- a/gerbonara/gerber/apertures.py +++ b/gerbonara/gerber/apertures.py @@ -3,6 +3,7 @@ import math from dataclasses import dataclass, replace, fields, InitVar, KW_ONLY from .aperture_macros.parse import GenericMacros +from .utils import convert_units from . import graphic_primitives as gp @@ -11,11 +12,11 @@ def _flash_hole(self, x, y, unit=None): if getattr(self, 'hole_rect_h', None) is not None: return [*self.primitives(x, y, unit), gp.Rectangle((x, y), - (self.convert(self.hole_dia, unit), self.convert(self.hole_rect_h, unit)), + (self.convert_to(self.hole_dia, unit), self.convert_to(self.hole_rect_h, unit)), rotation=self.rotation, polarity_dark=False)] elif self.hole_dia is not None: return [*self.primitives(x, y, unit), - gp.Circle(x, y, self.convert(self.hole_dia/2, unit), polarity_dark=False)] + gp.Circle(x, y, self.convert_to(self.hole_dia/2, unit), polarity_dark=False)] else: return self.primitives(x, y, unit) @@ -39,30 +40,16 @@ class Aperture: @property def hole_shape(self): - if self.hole_rect_h is not None: + if hasattr(self, 'hole_rect_h') and self.hole_rect_h is not None: return 'rect' else: return 'circle' - @property - def hole_size(self): - return (self.hole_dia, self.hole_rect_h) - def convert(self, value, unit): - if self.unit == unit or self.unit is None or unit is None or value is None: - return value - elif unit == 'mm': - return value * 25.4 - else: - return value / 25.4 + return convert_units(value, self.unit, unit) def convert_from(self, value, unit): - if self.unit == unit or self.unit is None or unit is None or value is None: - return value - elif unit == 'mm': - return value / 25.4 - else: - return value * 25.4 + return convert_units(value, unit, self.unit) def params(self, unit=None): out = [] @@ -72,7 +59,7 @@ class Aperture: val = getattr(self, f.name) if isinstance(f.type, Length): - val = self.convert(val, unit) + val = self.convert_to(val, unit) out.append(val) return out @@ -103,6 +90,55 @@ class Aperture: else: return {'hole_dia': self.hole_rect_h, 'hole_rect_h': self.hole_dia} +@dataclass(frozen=True) +class ExcellonTool(Aperture): + human_readable_shape = 'drill' + diameter : Length(float) + plated : bool = None + depth_offset : Length(float) = 0 + + def primitives(self, x, y, unit=None): + return [ gp.Circle(x, y, self.convert_to(self.diameter/2, unit)) ] + + def to_xnc(self, settings): + z_off += 'Z' + settings.write_gerber_value(self.depth_offset) if self.depth_offset is not None else '' + return 'C' + settings.write_gerber_value(self.diameter) + z_off + + def __eq__(self, other): + if not isinstance(other, ExcellonTool): + return False + + if not self.plated == other.plated: + return False + + if not math.isclose(self.depth_offset, self.unit.from(other.unit, other.depth_offset)): + return False + + return math.isclose(self.diameter, self.unit.from(other.unit, other.diameter)) + + def __str__(self): + plated = '' if self.plated is None else (' plated' if self.plated else ' non-plated') + z_off = '' if self.depth_offset is None else f' z_offset={self.depth_offset}' + return f'' + + def equivalent_width(self, unit=None): + return self.convert_to(self.diameter, unit) + + def dilated(self, offset, unit='mm'): + offset = self.convert_from(offset, unit) + return replace(self, diameter=self.diameter+2*offset) + + def _rotated(self): + return self + else: + return self.to_macro(self.rotation) + + def to_macro(self): + return ApertureMacroInstance(GenericMacros.circle, self.params(unit='mm')) + + def params(self, unit=None): + return self.convert_to(self.diameter, unit) + @dataclass class CircleAperture(Aperture): @@ -114,7 +150,7 @@ class CircleAperture(Aperture): rotation : float = 0 # radians; for rectangular hole; see hack in Aperture.to_gerber def primitives(self, x, y, unit=None): - return [ gp.Circle(x, y, self.convert(self.diameter/2, unit)) ] + return [ gp.Circle(x, y, self.convert_to(self.diameter/2, unit)) ] def __str__(self): return f'' @@ -122,7 +158,7 @@ class CircleAperture(Aperture): flash = _flash_hole def equivalent_width(self, unit=None): - return self.convert(self.diameter, unit) + return self.convert_to(self.diameter, unit) def dilated(self, offset, unit='mm'): offset = self.convert_from(offset, unit) @@ -139,9 +175,9 @@ class CircleAperture(Aperture): def params(self, unit=None): return strip_right( - self.convert(self.diameter, unit), - self.convert(self.hole_dia, unit), - self.convert(self.hole_rect_h, unit)) + self.convert_to(self.diameter, unit), + self.convert_to(self.hole_dia, unit), + self.convert_to(self.hole_rect_h, unit)) @dataclass @@ -155,7 +191,7 @@ class RectangleAperture(Aperture): rotation : float = 0 # radians def primitives(self, x, y, unit=None): - return [ gp.Rectangle(x, y, self.convert(self.w, unit), self.convert(self.h, unit), rotation=self.rotation) ] + return [ gp.Rectangle(x, y, self.convert_to(self.w, unit), self.convert_to(self.h, unit), rotation=self.rotation) ] def __str__(self): return f'' @@ -163,7 +199,7 @@ class RectangleAperture(Aperture): flash = _flash_hole def equivalent_width(self, unit=None): - return self.convert(math.sqrt(self.w**2 + self.h**2), unit) + return self.convert_to(math.sqrt(self.w**2 + self.h**2), unit) def dilated(self, offset, unit='mm'): offset = self.convert_from(offset, unit) @@ -179,18 +215,18 @@ class RectangleAperture(Aperture): def to_macro(self): return ApertureMacroInstance(GenericMacros.rect, - [self.convert(self.w, 'mm'), - self.convert(self.h, 'mm'), - self.convert(self.hole_dia, 'mm') or 0, - self.convert(self.hole_rect_h, 'mm') or 0, + [self.convert_to(self.w, 'mm'), + self.convert_to(self.h, 'mm'), + self.convert_to(self.hole_dia, 'mm') or 0, + self.convert_to(self.hole_rect_h, 'mm') or 0, self.rotation]) def params(self, unit=None): return strip_right( - self.convert(self.w, unit), - self.convert(self.h, unit), - self.convert(self.hole_dia, unit), - self.convert(self.hole_rect_h, unit)) + self.convert_to(self.w, unit), + self.convert_to(self.h, unit), + self.convert_to(self.hole_dia, unit), + self.convert_to(self.hole_rect_h, unit)) @dataclass @@ -204,7 +240,7 @@ class ObroundAperture(Aperture): rotation : float = 0 def primitives(self, x, y, unit=None): - return [ gp.Obround(x, y, self.convert(self.w, unit), self.convert(self.h, unit), rotation=self.rotation) ] + return [ gp.Obround(x, y, self.convert_to(self.w, unit), self.convert_to(self.h, unit), rotation=self.rotation) ] def __str__(self): return f'' @@ -227,18 +263,18 @@ class ObroundAperture(Aperture): # generic macro only supports w > h so flip x/y if h > w inst = self if self.w > self.h else replace(self, w=self.h, h=self.w, **_rotate_hole_90(self), rotation=self.rotation-90) return ApertureMacroInstance(GenericMacros.obround, - [self.convert(inst.w, 'mm'), - self.convert(ints.h, 'mm'), - self.convert(inst.hole_dia, 'mm'), - self.convert(inst.hole_rect_h, 'mm'), + [self.convert_to(inst.w, 'mm'), + self.convert_to(ints.h, 'mm'), + self.convert_to(inst.hole_dia, 'mm'), + self.convert_to(inst.hole_rect_h, 'mm'), inst.rotation]) def params(self, unit=None): return strip_right( - self.convert(self.w, unit), - self.convert(self.h, unit), - self.convert(self.hole_dia, unit), - self.convert(self.hole_rect_h, unit)) + self.convert_to(self.w, unit), + self.convert_to(self.h, unit), + self.convert_to(self.hole_dia, unit), + self.convert_to(self.hole_rect_h, unit)) @dataclass @@ -253,7 +289,7 @@ class PolygonAperture(Aperture): self.n_vertices = int(self.n_vertices) def primitives(self, x, y, unit=None): - return [ gp.RegularPolygon(x, y, self.convert(self.diameter, unit)/2, self.n_vertices, rotation=self.rotation) ] + return [ gp.RegularPolygon(x, y, self.convert_to(self.diameter, unit)/2, self.n_vertices, rotation=self.rotation) ] def __str__(self): return f'<{self.n_vertices}-gon aperture d={self.diameter:.3}' @@ -273,11 +309,11 @@ class PolygonAperture(Aperture): def params(self, unit=None): rotation = self.rotation % (2*math.pi / self.n_vertices) if self.rotation is not None else None if self.hole_dia is not None: - return self.convert(self.diameter, unit), self.n_vertices, rotation, self.convert(self.hole_dia, unit) + return self.convert_to(self.diameter, unit), self.n_vertices, rotation, self.convert_to(self.hole_dia, unit) elif rotation is not None and not math.isclose(rotation, 0): - return self.convert(self.diameter, unit), self.n_vertices, rotation + return self.convert_to(self.diameter, unit), self.n_vertices, rotation else: - return self.convert(self.diameter, unit), self.n_vertices + return self.convert_to(self.diameter, unit), self.n_vertices @dataclass class ApertureMacroInstance(Aperture): diff --git a/gerbonara/gerber/cam.py b/gerbonara/gerber/cam.py index 12906a0..f42c24d 100644 --- a/gerbonara/gerber/cam.py +++ b/gerbonara/gerber/cam.py @@ -92,8 +92,11 @@ class FileSettings: else: # no or trailing zero suppression return float(sign + value[:integer_digits] + '.' + value[integer_digits:]) - def write_gerber_value(self, value): + def write_gerber_value(self, value, unit=None): """ Convert a floating point number to a Gerber/Excellon-formatted string. """ + + if unit is not None: + value = self.unit.from(unit, value) integer_digits, decimal_digits = self.number_format diff --git a/gerbonara/gerber/excellon.py b/gerbonara/gerber/excellon.py index 550d783..c9d76d6 100755 --- a/gerbonara/gerber/excellon.py +++ b/gerbonara/gerber/excellon.py @@ -15,307 +15,156 @@ # 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 import warnings from enum import Enum +from dataclasses import dataclass +from collections import Counter from .cam import CamFile, FileSettings from .excellon_statements import * -from .excellon_tool import ExcellonToolDefinitionParser from .graphic_objects import Drill, Slot -from .utils import inch, metric +from .apertures import ExcellonTool +from .utils import Inch, MM -try: - from cStringIO import StringIO -except(ImportError): - from io import StringIO +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 + def select_tool(self, tool): + if self.current_tool != tool: + self.current_tool = tool + yield f'T{tools[tool]:02d}' + def drill_mode(self): + if self.mode != ProgramState.DRILLING: + self.mode = ProgramState.DRILLING + yield 'G05' -def read(filename): - """ Read data from filename and return an ExcellonFile - Parameters - ---------- - filename : string - Filename of file to parse + def route_mode(self, unit, x, y): + x, y = self.unit.from(unit, x), self.unit.from(unit, y) - Returns - ------- - file : :class:`gerber.excellon.ExcellonFile` - An ExcellonFile created from the specified file. + if self.mode == ProgramState.ROUTING and (self.x, self.y) == (x, y): + return # nothing to do - """ - # File object should use settings from source file by default. - with open(filename, 'r') as f: - data = f.read() - settings = FileSettings(**detect_excellon_format(data)) - return ExcellonParser(settings).parse(filename) + yield 'G00' + 'X' + self.settings.write_gerber_value(x) + 'Y' + self.settings.write_gerber_value(y) -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 + def set_current_point(self, unit, x, y): + self.current_point = self.unit.from(unit, x), self.unit.from(unit, y) - filename : string, optional - string containing the filename of the data source - tools: dict (optional) - externally defined tools +class ExcellonFile(CamFile): + def __init__(self, filename=None) + super().__init__(filename=filename) + self.objects = [] + self.comments = [] + self.import_settings = None - Returns - ------- - file : :class:`gerber.excellon.ExcellonFile` - An ExcellonFile created from the specified file. + def _generate_statements(self, settings): - """ - # 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) + yield '; XNC file generated by gerbonara' + if self.comments: + yield '; Comments found in original file:' + for comment in self.comments: + yield ';' + comment + yield 'M48' + yield 'METRIC' if settings.unit == 'mm' else 'INCH' -class DrillHit(object): - """Drill feature that is a single drill hole. + # Build tool index + tools = set(obj.tool for obj in self.objects) + tools = sorted(tools, key=lambda tool: (tool.plated, tool.diameter, tool.depth_offset)) + tools = { tool: index for index, tool in enumerate(tools, start=1) } - 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. + if max(tools) >= 100: + warnings.warn('More than 99 tools defined. Some programs may not like three-digit tool indices.', SyntaxWarning) - """ - def __init__(self, tool, position): - self.tool = tool - self.position = position + for tool, index in tools.items(): + yield f'T{index:02d}' + tool.to_xnc(settings) - @property - def bounding_box(self): - position = self.position - radius = self.tool.diameter / 2. + yield '%' - 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)) + # Export objects + for obj in self.objects: + obj.to_xnc(ctx) - def offset(self, x_offset=0, y_offset=0): - self.position = tuple(map(operator.add, self.position, (x_offset, y_offset))) + yield 'M30' - def __str__(self): - return 'Hit (%f, %f) {%s}' % (self.position[0], self.position[1], self.tool) + def to_excellon(self, settings=None): + ''' Export to Excellon format. This function always generates XNC, which is a well-defined subset of Excellon. + ''' + if settings is None: + settings = self.import_settings.copy() or FileSettings() + settings.zeros = None + settings.number_format = (3,5) + return '\n'.join(self._generate_statements(settings)) -class DrillSlot(object): - """ - A slot is created between two points. The way the slot is created depends on the statement used to create it - """ + def offset(self, x=0, y=0, unit=MM): + self.objects = [ obj.with_offset(x, y, unit) for obj in self.objects ] - 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 rotate(self, angle, cx=0, cy=0, unit=MM): + for obj in self.objects: + obj.rotate(angle, cx, cy, unit=unit) @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) + 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) - Parameters - ---------- - tools : list - list of gerber file statements + @property + def is_nonplated(self): + return not any(obj.plated for obj in self.objects) - hits : list of tuples - list of drill hits as (, (x, y)) + def empty(self): + return self.objects.empty() - settings : dict - Dictionary of gerber file settings + def __len__(self): + return len(self.objects) - filename : string - Filename of the source gerber file + def split_by_plating(self): + plated, nonplated = ExcellonFile(self.filename), ExcellonFile(self.filename) - Attributes - ---------- - units : string - either 'inch' or 'metric'. + plated.comments = self.comments.copy() + plated.import_settings = self.import_settings.copy() + plated.objects = [ obj for obj in self.objects if obj.plated ] - """ + nonplated.comments = self.comments.copy() + nonplated.import_settings = self.import_settings.copy() + nonplated.objects = [ obj for obj in self.objects if not obj.plated ] - 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 + return nonplated, plated - @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 + def path_lengths(self, unit): + """ Calculate path lengths per tool. - @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)) + Returns: dict { tool: float(path length) } - 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 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 + 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 = {} - 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) + tool = None + for obj in sorted(self.objects, key=lambda obj: obj.tool): + if tool != obj.tool: + tool = obj.tool + lengths[tool] = 0 - 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 + lengths[tool] += obj.curve_length(unit) + return lengths + + def hit_count(self): + return Counter(obj.tool for obj in self.objects) class RegexMatcher: def __init__(self): @@ -358,7 +207,6 @@ class ExcellonParser(object): self.pos = 0, 0 self.drill_down = False self.is_plated = None - self.feed_rate = None @property def coordinates(self): @@ -391,7 +239,7 @@ class ExcellonParser(object): return self.parse_raw(data, filename) def parse_raw(self, data, filename=None): - for line in StringIO(data): + for line in data.splitlines(): self._parse_line(line.strip()) for stmt in self.statements: stmt.units = self.units @@ -424,32 +272,81 @@ class ExcellonParser(object): exprs = RegexMatcher() - @exprs.match(';(?PFILE_FORMAT=(?P[0-9]:[0-9])|TYPE=(?PPLATED|NON_PLATED)|(?P
HEADER:)|.*(?P Holesize)|.*)') - def parse_comment(self, match): + # NOTE: These must be kept before the generic comment handler at the end of this class so they match first. + @exprs.match(';T(?P[0-9]+) Holesize (?P[0-9]+)\. = (?P[0-9/.]+) Tolerance = \+[0-9/.]+/-[0-9/.]+ (?PPLATED|NON_PLATED|OPTIONAL) (?PMILS|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. - # get format from altium comment - if (fmt := match['format']): - x, _, y = fmt.partition(':') - self.settings.number_format = int(x), int(y) + 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!') - elif (plating := match('plating']): - self.is_plated = (plating == 'PLATED') + if index in self.tools: + warnings.warn('Re-definition of tool index {index}, overwriting old definition.', SyntaxWarning) - elif match['header']: - self.program_state = ProgramState.HEADER + # 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')) - elif match['tooldef']: - self.program_state = ProgramState.HEADER - - # 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) + diameter = float(match['diameter']) + if match['unit'] == 'MILS': + diameter /= 1000 + unit = Inch else: - target.comments.append(match['comment'].strip()) + unit = MM + + 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. + easyeda_comment = re.compile(';Holesize (?P[0-9]+) = (?P[.0-9]+) (?PINCH|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: + warnings.warn('Re-definition of tool index {index}, overwriting old definition.', SyntaxWarning) + + tools[index] = tool + + @exprs.match('T([0-9]+)(([A-Z][.0-9]+)+)') # Tool definition: T** with at least one parameter + def parse_tool_definition(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: + warnings.warn('Re-definition of tool index {index}, overwriting old definition.', SyntaxWarning) + + params = { m[0]: 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) + + @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: + raise SyntaxError(f'Undefined tool index {index} selected.') + + self.active_tool = self.tools[index] + + @exprs.match(r'R(?P[0-9]+)' + xy_coord).match(line) + 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[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(fun): @functools.wraps(fun) @@ -524,7 +421,6 @@ class ExcellonParser(object): 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) @@ -545,50 +441,79 @@ class ExcellonParser(object): if self.active_tool: return self.active_tool - if (self.active_tool := self.tools.get(1)): + if (self.active_tool := self.tools.get(1)): # FIXME is this necessary? It seems pretty dumb. return self.active_tool warnings.warn('Routing command found before first tool definition.', SyntaxWarning) return None - @exprs.match('(?PG01|G02|G03)' + xy_coord + aij_coord): - def handle_linear_mode(self, match) + @exprs.match('(?PG01|G02|G03)' + xy_coord + aij_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 match['mode'] == 'G01': - self.interpolation_mode = InterpMode.LINEAR + # Yes, drills in the header doesn't follow the specification, but it there are many files like this + if self.program_state not in (ProgramState.DRILLING, ProgramState.HEADER): + 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: warnings.warn('A/I/J arc coordinates found in linear mode.', SyntaxWarning) + self.objects.append(Line(*start, *end, self.active_tool, unit=self.settings.unit)) + else: - 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) - if a and (i or j): - warnings.warn('Arc without both radius and center specified.', SyntaxWarning) - - if self.drill_down: - if not self.ensure_active_tool(): - return + clockwise = (self.interpolation_mode == InterpMode.CIRCULAR_CW) + + if a: + if i or j: + warnings.warn('Arc without both radius and center specified.', SyntaxWarning) + + r = settings.parse_gerber_value(a) + # from https://math.stackexchange.com/a/1781546 + 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: + i = settings.parse_gerber_value(i) + j = settings.parse_gerber_value(j) - # FIXME handle arcs - # FIXME fix the API below - self.hits.append(DrillSlot(self.active_tool, start, end, DrillSlot.TYPE_ROUT)) - self.active_tool._hit() + self.objects.append(Arc(*start, *end, i, j, True, self.active_tool, unit=self.settings.unit)) - @exprs.match('M71') + @exprs.match('M71|METRIC') # XNC uses "METRIC" @header_command def handle_metric_mode(self, match): - self.settings.unit = 'mm' + self.settings.unit = MM - @exprs.match('M72') + @exprs.match('M72|INCH') # XNC uses "INCH" @header_command def handle_inch_mode(self, match): - self.settings.unit = 'inch' + self.settings.unit = Inch @exprs.match('G90') @header_command @@ -607,111 +532,42 @@ class ExcellonParser(object): 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 - - # 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 - - # FIXME fix 'ExcellonTool' API below - self.tools[index] = ExcellonTool( self._settings(), number=stmt.tool, diameter=diameter) - - @exprs.match(r'R(?P[0-9]+)' + xy_coord).match(line) - 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[0] += dx - self.pos[1] += dy - # FIXME fix API below - if not self.ensure_active_tool(): - return - - self.hits.append(DrillHit(self.active_tool, tuple(self.pos))) - self.active_tool._hit() + @exprs.match('G40|G41|G42|{coord("F")}') + def handle_unhandled(self, match): + warnings.warn(f'{match[0]} excellon command intended for CAM tools found in EDA file.', SyntaxWarning) @exprs.match(coord('X', 'x1') + coord('Y', 'y1') + 'G85' + coord('X', 'x2') + coord('Y', 'y2')) def handle_slot_dotted(self, match): + warnings.warn('Weird G85 excellon slot command used. Please raise an issue on our issue tracker and provide this file for testing.', SyntaxWarning) 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 - - self.hits.append(DrillSlot(self.active_tool, start, end, DrillSlot.TYPE_G85)) - self.active_tool._hit() - + 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(xy_coord) def handle_naked_coordinate(self, match): - start, end = self.do_move(match) - - # FIXME handle arcs - - # 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 - - self.hits.append(DrillSlot(self.active_tool, start, end, DrillSlot.TYPE_ROUT)) - - # 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() + self.do_interpolation(match) + + @exprs.match(';FILE_FORMAT=([0-9]:[0-9])') + def parse_altium_number_format_comment(self, match): + x, _, y = fmt.partition(':') + self.settings.number_format = int(x), int(y) + + @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(';HEADER:') + def parse_allegro_start_of_header(self, match): + self.program_state = ProgramState.HEADER - else: - warnings.warn('Found unexpected coordinate', SyntaxWarning) + @exprs.match(';(.*)') + def parse_comment(self, match) + target.comments.append(match[1].strip()) diff --git a/gerbonara/gerber/excellon_statements.py b/gerbonara/gerber/excellon_statements.py deleted file mode 100644 index 84e3bd3..0000000 --- a/gerbonara/gerber/excellon_statements.py +++ /dev/null @@ -1,871 +0,0 @@ -#!/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 Statements -==================== -**Excellon file statement classes** - -""" - -import re -import uuid -import itertools -from enum import Enum -from .utils import (decimal_string, - inch, metric) - - -__all__ = ['ExcellonTool', 'ToolSelectionStmt', 'CoordinateStmt', - 'CommentStmt', 'HeaderBeginStmt', 'HeaderEndStmt', - 'RewindStopStmt', 'EndOfProgramStmt', 'UnitStmt', - 'IncrementalModeStmt', 'VersionStmt', 'FormatStmt', 'LinkToolStmt', - 'MeasuringModeStmt', 'RouteModeStmt', 'LinearModeStmt', 'DrillModeStmt', - 'AbsoluteModeStmt', 'RepeatHoleStmt', 'UnknownStmt', - 'ExcellonStatement', 'ZAxisRoutPositionStmt', - 'RetractWithClampingStmt', 'RetractWithoutClampingStmt', - 'CutterCompensationOffStmt', 'CutterCompensationLeftStmt', - 'CutterCompensationRightStmt', 'ZAxisInfeedRateStmt', - 'NextToolSelectionStmt', 'SlotStmt'] - - -class Plating(Enum): - UNKNOWN = 0 - NONPLATED = 1 - PLATED = 2 - OPTIONAL = 3 - -class ExcellonStatement: - pass - -class ExcellonTool(ExcellonStatement): - """ Excellon Tool class - - Parameters - ---------- - settings : FileSettings (dict-like) - File-wide settings. - - kwargs : dict-like - Tool settings from the excellon statement. Valid keys are: - - `diameter` : Tool diameter [expressed in file units] - - `rpm` : Tool RPM - - `feed_rate` : Z-axis tool feed rate - - `retract_rate` : Z-axis tool retraction rate - - `max_hit_count` : Number of hits allowed before a tool change - - `depth_offset` : Offset of tool depth from tip of tool. - - Attributes - ---------- - number : integer - Tool number from the excellon file - - diameter : float - Tool diameter in file units - - rpm : float - Tool RPM - - feed_rate : float - Tool Z-axis feed rate. - - retract_rate : float - Tool Z-axis retract rate - - depth_offset : float - Offset of depth measurement from tip of tool - - max_hit_count : integer - Maximum number of tool hits allowed before a tool change - - hit_count : integer - Number of tool hits in excellon file. - """ - - @classmethod - def from_dict(cls, settings, tool_dict): - """ Create an ExcellonTool from a dict. - - Parameters - ---------- - settings : FileSettings (dict-like) - Excellon File-wide settings - - tool_dict : dict - Excellon tool parameters as a dict - - Returns - ------- - tool : ExcellonTool - An ExcellonTool initialized with the parameters in tool_dict. - """ - return cls(settings, **tool_dict) - - def __init__(self, settings, **kwargs): - if kwargs.get('id') is not None: - super(ExcellonTool, self).__init__(id=kwargs.get('id')) - self.settings = settings - self.number = kwargs.get('number') - self.feed_rate = kwargs.get('feed_rate') - self.retract_rate = kwargs.get('retract_rate') - self.rpm = kwargs.get('rpm') - self.diameter = kwargs.get('diameter') - self.max_hit_count = kwargs.get('max_hit_count') - self.depth_offset = kwargs.get('depth_offset') - self.plated = kwargs.get('plated') - - self.hit_count = 0 - - def to_excellon(self, settings=None): - if self.settings and not settings: - settings = self.settings - stmt = 'T%02d' % self.number - if self.retract_rate is not None: - stmt += 'B%s' % settings.write_gerber_value(self.retract_rate) - if self.feed_rate is not None: - stmt += 'F%s' % settings.write_gerber_value(self.feed_rate) - if self.max_hit_count is not None: - stmt += 'H%s' % settings.write_gerber_value(self.max_hit_count) - if self.rpm is not None: - if self.rpm < 100000.: - stmt += 'S%s' % settings.write_gerber_value(self.rpm / 1000.) - else: - stmt += 'S%g' % (self.rpm / 1000.) - if self.diameter is not None: - stmt += 'C%s' % decimal_string(self.diameter, fmt[1], True) - if self.depth_offset is not None: - stmt += 'Z%s' % settings.write_gerber_value(self.depth_offset) - return stmt - - def to_inch(self): - if self.settings.units != 'inch': - self.settings.units = 'inch' - if self.diameter is not None: - self.diameter = inch(self.diameter) - - def to_metric(self): - if self.settings.units != 'metric': - self.settings.units = 'metric' - if self.diameter is not None: - self.diameter = metric(self.diameter) - - def _hit(self): - self.hit_count += 1 - - def equivalent(self, other): - """ - Is the other tool equal to this, ignoring the tool number, and other file specified properties - """ - - if type(self) != type(other): - return False - - return (self.diameter == other.diameter - and self.feed_rate == other.feed_rate - and self.retract_rate == other.retract_rate - and self.rpm == other.rpm - and self.depth_offset == other.depth_offset - and self.max_hit_count == other.max_hit_count - and self.plated == other.plated - and self.settings.units == other.settings.units) - - def __repr__(self): - unit = 'in.' if self.settings.units == 'inch' else 'mm' - fmtstr = '' % self.settings.format - return fmtstr % (self.number, self.diameter, unit) - - -class ToolSelectionStmt(ExcellonStatement): - - @classmethod - def from_excellon(cls, line, **kwargs): - """ Create a ToolSelectionStmt from an excellon file line. - - Parameters - ---------- - line : string - Line from an Excellon file - - Returns - ------- - tool_statement : ToolSelectionStmt - ToolSelectionStmt representation of `line.` - """ - line = line[1:] - compensation_index = None - - # up to 3 characters for tool number (Frizting uses that) - if len(line) <= 3: - tool = int(line) - else: - tool = int(line[:2]) - compensation_index = int(line[2:]) - - return cls(tool, compensation_index, **kwargs) - - def __init__(self, tool, compensation_index=None, **kwargs): - super(ToolSelectionStmt, self).__init__(**kwargs) - tool = int(tool) - compensation_index = (int(compensation_index) if compensation_index - is not None else None) - self.tool = tool - self.compensation_index = compensation_index - - def to_excellon(self, settings=None): - stmt = 'T%02d' % self.tool - if self.compensation_index is not None: - stmt += '%02d' % self.compensation_index - return stmt - -class NextToolSelectionStmt(ExcellonStatement): - - # TODO the statement exists outside of the context of the file, - # so it is imposible to know that it is really the next tool - - def __init__(self, cur_tool, next_tool, **kwargs): - """ - Select the next tool in the wheel. - Parameters - ---------- - cur_tool : the tool that is currently selected - next_tool : the that that is now selected - """ - super(NextToolSelectionStmt, self).__init__(**kwargs) - - self.cur_tool = cur_tool - self.next_tool = next_tool - - def to_excellon(self, settings=None): - stmt = 'M00' - return stmt - -class ZAxisInfeedRateStmt(ExcellonStatement): - - @classmethod - def from_excellon(cls, line, **kwargs): - """ Create a ZAxisInfeedRate from an excellon file line. - - Parameters - ---------- - line : string - Line from an Excellon file - - Returns - ------- - z_axis_infeed_rate : ToolSelectionStmt - ToolSelectionStmt representation of `line.` - """ - rate = int(line[1:]) - - return cls(rate, **kwargs) - - def __init__(self, rate, **kwargs): - super(ZAxisInfeedRateStmt, self).__init__(**kwargs) - self.rate = rate - - def to_excellon(self, settings=None): - return 'F%02d' % self.rate - - -class CoordinateStmt(ExcellonStatement): - - @classmethod - def from_point(cls, point, mode=None): - - stmt = cls(point[0], point[1]) - if mode: - stmt.mode = mode - return stmt - - @classmethod - def from_excellon(cls, line, settings, **kwargs): - x_coord = None - y_coord = None - if line[0] == 'X': - splitline = line.strip('X').split('Y') - x_coord = settings.parse_gerber_value(splitline[0]) - if len(splitline) == 2: - y_coord = settings.parse_gerber_value(splitline[1]) - else: - y_coord = settings.parse_gerber_value(line.strip(' Y')) - c = cls(x_coord, y_coord, **kwargs) - c.units = settings.units - return c - - def __init__(self, x=None, y=None, **kwargs): - super(CoordinateStmt, self).__init__(**kwargs) - self.x = x - self.y = y - self.mode = None - - def to_excellon(self, settings): - stmt = '' - if self.mode == "ROUT": - stmt += "G00" - if self.mode == "LINEAR": - stmt += "G01" - if self.x is not None: - stmt += 'X%s' % settings.write_gerber_value(self.x) - if self.y is not None: - stmt += 'Y%s' % settings.write_gerber_value(self.y) - return stmt - - def to_inch(self): - if self.units == 'metric': - self.units = 'inch' - if self.x is not None: - self.x = inch(self.x) - if self.y is not None: - self.y = inch(self.y) - - def to_metric(self): - if self.units == 'inch': - self.units = 'metric' - if self.x is not None: - self.x = metric(self.x) - if self.y is not None: - self.y = metric(self.y) - - def offset(self, x_offset=0, y_offset=0): - if self.x is not None: - self.x += x_offset - if self.y is not None: - self.y += y_offset - - def __str__(self): - coord_str = '' - if self.x is not None: - coord_str += 'X: %g ' % self.x - if self.y is not None: - coord_str += 'Y: %g ' % self.y - - return '' % coord_str - - -class RepeatHoleStmt(ExcellonStatement): - - @classmethod - def from_excellon(cls, line, settings, **kwargs): - match = re.compile(r'R(?P[0-9]*)X?(?P[+\-]?\d*\.?\d*)?Y?' - '(?P[+\-]?\d*\.?\d*)?').match(line) - stmt = match.groupdict() - count = int(stmt['rcount']) - xdelta = (settings.parse_gerber_value(stmt['xdelta']) - if stmt['xdelta'] is not '' else None) - ydelta = (settings.parse_gerber_value(stmt['ydelta']) - if stmt['ydelta'] is not '' else None) - c = cls(count, xdelta, ydelta, **kwargs) - c.units = settings.units - return c - - def __init__(self, count, xdelta=0.0, ydelta=0.0, **kwargs): - super(RepeatHoleStmt, self).__init__(**kwargs) - self.count = count - self.xdelta = xdelta - self.ydelta = ydelta - - def to_excellon(self, settings): - stmt = 'R%d' % self.count - if self.xdelta is not None and self.xdelta != 0.0: - stmt += 'X%s' % settings.write_gerber_value(self.xdelta) - if self.ydelta is not None and self.ydelta != 0.0: - stmt += 'Y%s' % settings.write_gerber_value(self.ydelta) - return stmt - - def to_inch(self): - if self.units == 'metric': - self.units = 'inch' - if self.xdelta is not None: - self.xdelta = inch(self.xdelta) - if self.ydelta is not None: - self.ydelta = inch(self.ydelta) - - def to_metric(self): - if self.units == 'inch': - self.units = 'metric' - if self.xdelta is not None: - self.xdelta = metric(self.xdelta) - if self.ydelta is not None: - self.ydelta = metric(self.ydelta) - - def __str__(self): - return '' % ( - self.count, - self.xdelta if self.xdelta is not None else 0, - self.ydelta if self.ydelta is not None else 0) - - -class CommentStmt(ExcellonStatement): - - def __init__(self, comment): - self.comment = comment - - def to_excellon(self, settings=None): - return ';' + self.comment - - -class HeaderBeginStmt(ExcellonStatement): - - def __init__(self, **kwargs): - super(HeaderBeginStmt, self).__init__(**kwargs) - - def to_excellon(self, settings=None): - return 'M48' - - -class HeaderEndStmt(ExcellonStatement): - - def __init__(self, **kwargs): - super(HeaderEndStmt, self).__init__(**kwargs) - - def to_excellon(self, settings=None): - return 'M95' - - -class RewindStopStmt(ExcellonStatement): - - def __init__(self, **kwargs): - super(RewindStopStmt, self).__init__(**kwargs) - - def to_excellon(self, settings=None): - return '%' - - -class ZAxisRoutPositionStmt(ExcellonStatement): - - def __init__(self, **kwargs): - super(ZAxisRoutPositionStmt, self).__init__(**kwargs) - - def to_excellon(self, settings=None): - return 'M15' - - -class RetractWithClampingStmt(ExcellonStatement): - - def __init__(self, **kwargs): - super(RetractWithClampingStmt, self).__init__(**kwargs) - - def to_excellon(self, settings=None): - return 'M16' - - -class RetractWithoutClampingStmt(ExcellonStatement): - - def __init__(self, **kwargs): - super(RetractWithoutClampingStmt, self).__init__(**kwargs) - - def to_excellon(self, settings=None): - return 'M17' - - -class CutterCompensationOffStmt(ExcellonStatement): - - def __init__(self, **kwargs): - super(CutterCompensationOffStmt, self).__init__(**kwargs) - - def to_excellon(self, settings=None): - return 'G40' - - -class CutterCompensationLeftStmt(ExcellonStatement): - - def __init__(self, **kwargs): - super(CutterCompensationLeftStmt, self).__init__(**kwargs) - - def to_excellon(self, settings=None): - return 'G41' - - -class CutterCompensationRightStmt(ExcellonStatement): - - def __init__(self, **kwargs): - super(CutterCompensationRightStmt, self).__init__(**kwargs) - - def to_excellon(self, settings=None): - return 'G42' - - -class EndOfProgramStmt(ExcellonStatement): - - @classmethod - def from_excellon(cls, line, settings, **kwargs): - match = re.compile(r'M30X?(?P\d*\.?\d*)?Y?' - '(?P\d*\.?\d*)?').match(line) - stmt = match.groupdict() - x = (settings.parse_gerber_value(stmt['x']) - if stmt['x'] is not '' else None) - y = (settings.parse_gerber_value(stmt['y']) - if stmt['y'] is not '' else None) - c = cls(x, y, **kwargs) - c.units = settings.units - return c - - def __init__(self, x=None, y=None, **kwargs): - super(EndOfProgramStmt, self).__init__(**kwargs) - self.x = x - self.y = y - - def to_excellon(self, settings): - stmt = 'M30' - if self.x is not None: - stmt += 'X%s' % settings.write_gerber_value(self.x) - if self.y is not None: - stmt += 'Y%s' % settings.write_gerber_value(self.y) - return stmt - - def to_inch(self): - if self.units == 'metric': - self.units = 'inch' - if self.x is not None: - self.x = inch(self.x) - if self.y is not None: - self.y = inch(self.y) - - def to_metric(self): - if self.units == 'inch': - self.units = 'metric' - if self.x is not None: - self.x = metric(self.x) - if self.y is not None: - self.y = metric(self.y) - - def offset(self, x_offset=0, y_offset=0): - if self.x is not None: - self.x += x_offset - if self.y is not None: - self.y += y_offset - - -class UnitStmt(ExcellonStatement): - - @classmethod - def from_settings(cls, settings): - """Create the unit statement from the FileSettings""" - - return cls(settings.units, settings.zeros) - - @classmethod - def from_excellon(cls, line, **kwargs): - units = 'inch' if 'INCH' in line else 'metric' - zeros = 'leading' if 'LZ' in line else 'trailing' - if '0000.00' in line: - format = (4, 2) - elif '000.000' in line: - format = (3, 3) - elif '00.0000' in line: - format = (2, 4) - else: - format = None - return cls(units, zeros, format, **kwargs) - - def __init__(self, units='inch', zeros='leading', format=None, **kwargs): - super(UnitStmt, self).__init__(**kwargs) - self.units = units.lower() - self.zeros = zeros - self.format = format - - def to_excellon(self, settings=None): - # TODO This won't export the invalid format statement if it exists - stmt = '%s,%s' % ('INCH' if self.units == 'inch' else 'METRIC', - 'LZ' if self.zeros == 'leading' - else 'TZ') - return stmt - - def to_inch(self): - self.units = 'inch' - - def to_metric(self): - self.units = 'metric' - - -class IncrementalModeStmt(ExcellonStatement): - - @classmethod - def from_excellon(cls, line, **kwargs): - return cls('off', **kwargs) if 'OFF' in line else cls('on', **kwargs) - - def __init__(self, mode='off', **kwargs): - super(IncrementalModeStmt, self).__init__(**kwargs) - if mode.lower() not in ['on', 'off']: - raise ValueError('Mode may be "on" or "off"') - self.mode = mode - - def to_excellon(self, settings=None): - return 'ICI,%s' % ('OFF' if self.mode == 'off' else 'ON') - - -class VersionStmt(ExcellonStatement): - - @classmethod - def from_excellon(cls, line, **kwargs): - version = int(line.split(',')[1]) - return cls(version, **kwargs) - - def __init__(self, version=1, **kwargs): - super(VersionStmt, self).__init__(**kwargs) - version = int(version) - if version not in [1, 2]: - raise ValueError('Valid versions are 1 or 2') - self.version = version - - def to_excellon(self, settings=None): - return 'VER,%d' % self.version - - -class FormatStmt(ExcellonStatement): - - @classmethod - def from_excellon(cls, line, **kwargs): - fmt = int(line.split(',')[1]) - return cls(fmt, **kwargs) - - def __init__(self, format=1, **kwargs): - super(FormatStmt, self).__init__(**kwargs) - format = int(format) - if format not in [1, 2]: - raise ValueError('Valid formats are 1 or 2') - self.format = format - - def to_excellon(self, settings=None): - return 'FMAT,%d' % self.format - - @property - def format_tuple(self): - return (self.format, 6 - self.format) - - -class LinkToolStmt(ExcellonStatement): - - @classmethod - def from_excellon(cls, line, **kwargs): - linked = [int(tool) for tool in line.split('/')] - return cls(linked, **kwargs) - - def __init__(self, linked_tools, **kwargs): - super(LinkToolStmt, self).__init__(**kwargs) - self.linked_tools = [int(x) for x in linked_tools] - - def to_excellon(self, settings=None): - return '/'.join([str(x) for x in self.linked_tools]) - - -class MeasuringModeStmt(ExcellonStatement): - - @classmethod - def from_excellon(cls, line, **kwargs): - if not ('M71' in line or 'M72' in line): - raise ValueError('Not a measuring mode statement') - return cls('inch', **kwargs) if 'M72' in line else cls('metric', **kwargs) - - def __init__(self, units='inch', **kwargs): - super(MeasuringModeStmt, self).__init__(**kwargs) - units = units.lower() - if units not in ['inch', 'metric']: - raise ValueError('units must be "inch" or "metric"') - self.units = units - - def to_excellon(self, settings=None): - return 'M72' if self.units == 'inch' else 'M71' - - def to_inch(self): - self.units = 'inch' - - def to_metric(self): - self.units = 'metric' - - -class RouteModeStmt(ExcellonStatement): - - def __init__(self, **kwargs): - super(RouteModeStmt, self).__init__(**kwargs) - - def to_excellon(self, settings=None): - return 'G00' - - -class LinearModeStmt(ExcellonStatement): - - def __init__(self, **kwargs): - super(LinearModeStmt, self).__init__(**kwargs) - - def to_excellon(self, settings=None): - return 'G01' - - -class DrillModeStmt(ExcellonStatement): - - def __init__(self, **kwargs): - super(DrillModeStmt, self).__init__(**kwargs) - - def to_excellon(self, settings=None): - return 'G05' - - -class AbsoluteModeStmt(ExcellonStatement): - - def __init__(self, **kwargs): - super(AbsoluteModeStmt, self).__init__(**kwargs) - - def to_excellon(self, settings=None): - return 'G90' - - -class UnknownStmt(ExcellonStatement): - - @classmethod - def from_excellon(cls, line, **kwargs): - return cls(line, **kwargs) - - def __init__(self, stmt, **kwargs): - super(UnknownStmt, self).__init__(**kwargs) - self.stmt = stmt - - def to_excellon(self, settings=None): - return self.stmt - - def __str__(self): - return "" % self.stmt - - -class SlotStmt(ExcellonStatement): - """ - G85 statement. Defines a slot created by multiple drills between two specified points. - - Format is two coordinates, split by G85in the middle, for example, XnY0nG85XnYn - """ - - @classmethod - def from_points(cls, start, end): - - return cls(start[0], start[1], end[0], end[1]) - - @classmethod - def from_excellon(cls, line, settings, **kwargs): - # Split the line based on the G85 separator - sub_coords = line.split('G85') - (x_start_coord, y_start_coord) = SlotStmt.parse_sub_coords(sub_coords[0], settings) - (x_end_coord, y_end_coord) = SlotStmt.parse_sub_coords(sub_coords[1], settings) - - # Some files seem to specify only one of the coordinates - if x_end_coord == None: - x_end_coord = x_start_coord - if y_end_coord == None: - y_end_coord = y_start_coord - - c = cls(x_start_coord, y_start_coord, x_end_coord, y_end_coord, **kwargs) - c.units = settings.units - return c - - @staticmethod - def parse_sub_coords(line, settings): - - x_coord = None - y_coord = None - - if line[0] == 'X': - splitline = line.strip('X').split('Y') - x_coord = settings.parse_gerber_value(splitline[0]) - if len(splitline) == 2: - y_coord = settings.parse_gerber_value(splitline[1]) - else: - y_coord = settings.parse_gerber_value(line.strip(' Y')) - - return (x_coord, y_coord) - - - def __init__(self, x_start=None, y_start=None, x_end=None, y_end=None, **kwargs): - super(SlotStmt, self).__init__(**kwargs) - self.x_start = x_start - self.y_start = y_start - self.x_end = x_end - self.y_end = y_end - self.mode = None - - def to_excellon(self, settings): - stmt = '' - - if self.x_start is not None: - stmt += 'X%s' % settings.write_gerber_value(self.x_start) - if self.y_start is not None: - stmt += 'Y%s' % settings.write_gerber_value(self.y_start) - - stmt += 'G85' - - if self.x_end is not None: - stmt += 'X%s' % settings.write_gerber_value(self.x_end) - if self.y_end is not None: - stmt += 'Y%s' % settings.write_gerber_value(self.y_end) - - return stmt - - def to_inch(self): - if self.units == 'metric': - self.units = 'inch' - if self.x_start is not None: - self.x_start = inch(self.x_start) - if self.y_start is not None: - self.y_start = inch(self.y_start) - if self.x_end is not None: - self.x_end = inch(self.x_end) - if self.y_end is not None: - self.y_end = inch(self.y_end) - - def to_metric(self): - if self.units == 'inch': - self.units = 'metric' - if self.x_start is not None: - self.x_start = metric(self.x_start) - if self.y_start is not None: - self.y_start = metric(self.y_start) - if self.x_end is not None: - self.x_end = metric(self.x_end) - if self.y_end is not None: - self.y_end = metric(self.y_end) - - def offset(self, x_offset=0, y_offset=0): - if self.x_start is not None: - self.x_start += x_offset - if self.y_start is not None: - self.y_start += y_offset - if self.x_end is not None: - self.x_end += x_offset - if self.y_end is not None: - self.y_end += y_offset - - def __str__(self): - start_str = '' - if self.x_start is not None: - start_str += 'X: %g ' % self.x_start - if self.y_start is not None: - start_str += 'Y: %g ' % self.y_start - - end_str = '' - if self.x_end is not None: - end_str += 'X: %g ' % self.x_end - if self.y_end is not None: - end_str += 'Y: %g ' % self.y_end - - return '' % (start_str, end_str) - -def pairwise(iterator): - """ Iterate over list taking two elements at a time. - - e.g. [1, 2, 3, 4, 5, 6] ==> [(1, 2), (3, 4), (5, 6)] - """ - a, b = itertools.tee(iterator) - itr = zip(itertools.islice(a, 0, None, 2), itertools.islice(b, 1, None, 2)) - for elem in itr: - yield elem diff --git a/gerbonara/gerber/excellon_tool.py b/gerbonara/gerber/excellon_tool.py deleted file mode 100644 index a9ac450..0000000 --- a/gerbonara/gerber/excellon_tool.py +++ /dev/null @@ -1,190 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -# Copyright 2015 Garret Fick - -# 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 Tool Definition File module -==================== -**Excellon file classes** - -This module provides Excellon file classes and parsing utilities -""" - -import re -try: - from cStringIO import StringIO -except(ImportError): - from io import StringIO - -from .excellon_statements import ExcellonTool - -def loads(data, settings=None): - """ Read tool file information and return a map of tools - Parameters - ---------- - data : string - string containing Excellon Tool Definition file contents - - Returns - ------- - dict tool name: ExcellonTool - - """ - return ExcellonToolDefinitionParser(settings).parse_raw(data) - -class ExcellonToolDefinitionParser(object): - """ Excellon File Parser - - Parameters - ---------- - None - """ - - allegro_tool = re.compile(r'(?P[0-9/.]+)\s+(?PP|N)\s+T(?P[0-9]{2})\s+(?P[0-9/.]+)\s+(?P[0-9/.]+)') - allegro_comment_mils = re.compile('Holesize (?P[0-9]{1,2})\. = (?P[0-9/.]+) Tolerance = \+(?P[0-9/.]+)/-(?P[0-9/.]+) (?P(PLATED)|(NON_PLATED)|(OPTIONAL)) MILS Quantity = [0-9]+') - allegro2_comment_mils = re.compile('T(?P[0-9]{1,2}) Holesize (?P[0-9]{1,2})\. = (?P[0-9/.]+) Tolerance = \+(?P[0-9/.]+)/-(?P[0-9/.]+) (?P(PLATED)|(NON_PLATED)|(OPTIONAL)) MILS Quantity = [0-9]+') - allegro_comment_mm = re.compile('Holesize (?P[0-9]{1,2})\. = (?P[0-9/.]+) Tolerance = \+(?P[0-9/.]+)/-(?P[0-9/.]+) (?P(PLATED)|(NON_PLATED)|(OPTIONAL)) MM Quantity = [0-9]+') - allegro2_comment_mm = re.compile('T(?P[0-9]{1,2}) Holesize (?P[0-9]{1,2})\. = (?P[0-9/.]+) Tolerance = \+(?P[0-9/.]+)/-(?P[0-9/.]+) (?P(PLATED)|(NON_PLATED)|(OPTIONAL)) MM Quantity = [0-9]+') - - matchers = [ - (allegro_tool, 'mils'), - (allegro_comment_mils, 'mils'), - (allegro2_comment_mils, 'mils'), - (allegro_comment_mm, 'mm'), - (allegro2_comment_mm, 'mm'), - ] - - def __init__(self, settings=None): - self.tools = {} - self.settings = settings - - def parse_raw(self, data): - for line in StringIO(data): - self._parse(line.strip()) - - return self.tools - - def _parse(self, line): - - for matcher in ExcellonToolDefinitionParser.matchers: - m = matcher[0].match(line) - if m: - unit = matcher[1] - - size = float(m.group('size')) - platedstr = m.group('plated') - toolid = int(m.group('toolid')) - xtol = float(m.group('xtol')) - ytol = float(m.group('ytol')) - - size = self._convert_length(size, unit) - xtol = self._convert_length(xtol, unit) - ytol = self._convert_length(ytol, unit) - - if platedstr == 'PLATED': - plated = ExcellonTool.PLATED_YES - elif platedstr == 'NON_PLATED': - plated = ExcellonTool.PLATED_NO - elif platedstr == 'OPTIONAL': - plated = ExcellonTool.PLATED_OPTIONAL - else: - plated = ExcellonTool.PLATED_UNKNOWN - - tool = ExcellonTool(None, number=toolid, diameter=size, - plated=plated) - - self.tools[tool.number] = tool - - break - - def _convert_length(self, value, unit): - - # Convert the value to mm - if unit == 'mils': - value /= 39.3700787402 - - # Now convert to the settings unit - if self.settings.units == 'inch': - return value / 25.4 - else: - # Already in mm - return value - -def loads_rep(data, settings=None): - """ Read tool report information generated by PADS and return a map of tools - Parameters - ---------- - data : string - string containing Excellon Report file contents - - Returns - ------- - dict tool name: ExcellonTool - - """ - return ExcellonReportParser(settings).parse_raw(data) - -class ExcellonReportParser(object): - - # We sometimes get files with different encoding, so we can't actually - # match the text - the best we can do it detect the table header - header = re.compile(r'====\s+====\s+====\s+====\s+=====\s+===') - - def __init__(self, settings=None): - self.tools = {} - self.settings = settings - - self.found_header = False - - def parse_raw(self, data): - for line in StringIO(data): - self._parse(line.strip()) - - return self.tools - - def _parse(self, line): - - # skip empty lines and "comments" - if not line.strip(): - return - - if not self.found_header: - # Try to find the heaader, since we need that to be sure we - # understand the contents correctly. - if ExcellonReportParser.header.match(line): - self.found_header = True - - elif line[0] != '=': - # Already found the header, so we know to to map the contents - parts = line.split() - if len(parts) == 6: - toolid = int(parts[0]) - size = float(parts[1]) - if parts[2] == 'x': - plated = ExcellonTool.PLATED_YES - elif parts[2] == '-': - plated = ExcellonTool.PLATED_NO - else: - plated = ExcellonTool.PLATED_UNKNOWN - feedrate = int(parts[3]) - speed = int(parts[4]) - qty = int(parts[5]) - - tool = ExcellonTool(None, number=toolid, diameter=size, - plated=plated, feed_rate=feedrate, - rpm=speed) - - self.tools[tool.number] = tool diff --git a/gerbonara/gerber/gerber_statements.py b/gerbonara/gerber/gerber_statements.py index 2291160..c2f1934 100644 --- a/gerbonara/gerber/gerber_statements.py +++ b/gerbonara/gerber/gerber_statements.py @@ -21,6 +21,8 @@ Gerber (RS-274X) Statements """ +# FIXME make this entire file obsolete and just return strings from graphical objects directly instead + def convert(value, src, dst): if src == dst or src is None or dst is None or value is None: return value diff --git a/gerbonara/gerber/graphic_objects.py b/gerbonara/gerber/graphic_objects.py index 82aac67..8f2e4b4 100644 --- a/gerbonara/gerber/graphic_objects.py +++ b/gerbonara/gerber/graphic_objects.py @@ -2,6 +2,7 @@ import math from dataclasses import dataclass, KW_ONLY, astuple, replace, fields +from .utils import MM from . import graphic_primitives as gp from .gerber_statements import * @@ -59,6 +60,14 @@ class Flash(GerberObject): y : Length(float) aperture : object + @property + def tool(self): + return self.aperture + + @tool.setter + def tool(self, value): + self.aperture = value + def _with_offset(self, dx, dy): return replace(self, x=self.x+dx, y=self.y+dy) @@ -75,6 +84,18 @@ class Flash(GerberObject): yield FlashStmt(self.x, self.y, unit=self.unit) gs.update_point(self.x, self.y, unit=self.unit) + def to_xnc(self, ctx): + yield from ctx.select_tool(self.tool) + yield from ctx.drill_mode() + x = ctx.settings.write_gerber_value(self.x, self.unit) + y = ctx.settings.write_gerber_value(self.y, self.unit) + yield f'X{x}Y{y}' + ctx.set_current_point(self.unit, self.x, self.y) + + def curve_length(self, unit=MM): + return 0 + + class Region(GerberObject): def __init__(self, outline=None, arc_centers=None, *, unit, polarity_dark): super().__init__(unit=unit, polarity_dark=polarity_dark) @@ -149,6 +170,7 @@ class Region(GerberObject): @dataclass class Line(GerberObject): # Line with *round* end caps. + x1 : Length(float) y1 : Length(float) x2 : Length(float) @@ -170,6 +192,18 @@ class Line(GerberObject): def p2(self): return self.x2, self.y2 + @property + def end_point(self): + return self.p2 + + @property + def tool(self): + return self.aperture + + @tool.setter + def tool(self, value): + self.aperture = value + def to_primitives(self, unit=None): conv = self.converted(unit) yield gp.Line(*conv.p1, *conv.p2, self.aperture.equivalent_width(unit), polarity_dark=self.polarity_dark) @@ -182,53 +216,14 @@ class Line(GerberObject): yield InterpolateStmt(*self.p2, unit=self.unit) gs.update_point(*self.p2, unit=self.unit) + def to_xnc(self, ctx): + yield from ctx.select_tool(self.tool) + yield from ctx.route_mode(self.unit, *self.p1) + yield 'G01' + 'X' + ctx.settings.write_gerber_value(self.p2[0], self.unit) + 'Y' + ctx.settings.write_gerber_value(self.p2[1], self.unit) + ctx.set_current_point(self.unit, *self.p2) -@dataclass -class Drill(GerberObject): - x : Length(float) - y : Length(float) - diameter : Length(float) - - def _with_offset(self, dx, dy): - return replace(self, x=self.x+dx, y=self.y+dy) - - def _rotate(self, angle, cx=0, cy=0): - self.x, self.y = gp.rotate_point(self.x, self.y, angle, cx, cy) - - def to_primitives(self, unit=None): - conv = self.converted(unit) - yield gp.Circle(conv.x, conv.y, conv.diameter/2) - - -@dataclass -class Slot(GerberObject): - x1 : Length(float) - y1 : Length(float) - x2 : Length(float) - y2 : Length(float) - width : Length(float) - - def _with_offset(self, dx, dy): - return replace(self, x1=self.x1+dx, y1=self.y1+dy, x2=self.x2+dx, y2=self.y2+dy) - - def _rotate(self, rotation, cx=0, cy=0): - if cx is None: - cx = (self.x1 + self.x2) / 2 - cy = (self.y1 + self.y2) / 2 - self.x1, self.y1 = gp.rotate_point(self.x1, self.y1, rotation, cx, cy) - self.x2, self.y2 = gp.rotate_point(self.x2, self.y2, rotation, cx, cy) - - @property - def p1(self): - return self.x1, self.y1 - - @property - def p2(self): - return self.x2, self.y2 - - def to_primitives(self, unit=None): - conv = self.converted(unit) - yield gp.Line(*conv.p1, *conv.p2, conv.width, polarity_dark=self.polarity_dark) + def curve_length(self, unit=MM): + return self.unit.to(unit, math.dist(self.p1, self.p2)) @dataclass @@ -258,6 +253,18 @@ class Arc(GerberObject): def center(self): return self.cx + self.x1, self.cy + self.y1 + @property + def end_point(self): + return self.p2 + + @property + def tool(self): + return self.aperture + + @tool.setter + def tool(self, value): + self.aperture = value + def _rotate(self, rotation, cx=0, cy=0): # rotate center first since we need old x1, y1 here new_cx, new_cy = gp.rotate_point(*self.center, rotation, cx, cy) @@ -282,4 +289,28 @@ class Arc(GerberObject): yield InterpolateStmt(self.x2, self.y2, self.cx, self.cy, unit=self.unit) gs.update_point(*self.p2, unit=self.unit) + def to_xnc(self, ctx): + yield from ctx.select_tool(self.tool) + yield from ctx.route_mode(self.unit, self.x1, self.y1) + code = 'G02' if self.clockwise else 'G03' + x = ctx.settings.write_gerber_value(self.x2, self.unit) + y = ctx.settings.write_gerber_value(self.y2, self.unit) + i = ctx.settings.write_gerber_value(self.cx - self.x1, self.unit) + j = ctx.settings.write_gerber_value(self.cy - self.y1, self.unit) + yield f'{code}X{x}Y{y}I{i}J{j}' + ctx.set_current_point(self.unit, self.x2, self.y2) + + def curve_length(self, unit=MM): + r = math.hypot(self.cx, self.cy) + f = math.atan2(self.x2, self.y2) - math.atan2(self.x1, self.y1) + f = (f + math.pi) % (2*math.pi) - math.pi + + if self.clockwise: + f = -f + + if f > math.pi: + f = 2*math.pi - f + + return self.unit.to(unit, 2*math.pi*r * (f/math.pi)) + diff --git a/gerbonara/gerber/graphic_primitives.py b/gerbonara/gerber/graphic_primitives.py index 1b9f09b..83b216e 100644 --- a/gerbonara/gerber/graphic_primitives.py +++ b/gerbonara/gerber/graphic_primitives.py @@ -164,6 +164,7 @@ def arc_bounds(x1, y1, x2, y2, cx, cy, clockwise): return (min_x+cx, min_y+cy), (max_x+cx, max_y+cy) +# FIXME use math.dist instead def point_distance(a, b): return math.sqrt((b[0] - a[0])**2 + (b[1] - a[1])**2) diff --git a/gerbonara/gerber/rs274x.py b/gerbonara/gerber/rs274x.py index e203780..4fad902 100644 --- a/gerbonara/gerber/rs274x.py +++ b/gerbonara/gerber/rs274x.py @@ -82,10 +82,11 @@ class GerberFile(CamFile): """ def __init__(self, filename=None): - super(GerberFile, self).__init__(filename) + super().__init__(filename) self.apertures = [] self.comments = [] self.objects = [] + self.import_settings = None def to_svg(self, tag=Tag, margin=0, arg_unit='mm', svg_unit='mm', force_bounds=None, color='black'): diff --git a/gerbonara/gerber/utils.py b/gerbonara/gerber/utils.py index 122dd5a..8e84c87 100644 --- a/gerbonara/gerber/utils.py +++ b/gerbonara/gerber/utils.py @@ -26,7 +26,42 @@ files. import os from math import radians, sin, cos, sqrt, atan2, pi + +class Unit: + def __init__(self, name, shorthand, this_in_mm): + self.name = name + self.shorthand = shorthand + self.factor = this_in_mm + + def from(self, unit, value): + if isinstance(unit, str): + unit = units[unit] + + if unit == self or unit is None or value is None: + return value + + return value * unit.factor / self.factor + + def to(self, unit, value): + if isinstance(unit, str): + unit = units[unit] + + if unit is None: + return value + + return unit.from(self, value) + + def __eq__(self, other): + if isinstance(other, str): + return other.lower() in (self.name, self.shorthand) + else: + return self == other + + MILLIMETERS_PER_INCH = 25.4 +Inch = Unit('inch', 'in', MILLIMETERS_PER_INCH) +MM = Unit('millimeter', 'mm', 1) +units = {'inch': Inch, 'mm': MM} def decimal_string(value, precision=6, padding=False): @@ -148,4 +183,11 @@ def sq_distance(point1, point2): diff2 = point1[1] - point2[1] return diff1 * diff1 + diff2 * diff2 +def convert_units(value, src, dst): + if src == dst or src is None or dst is None or value is None: + return value + elif dst == 'mm': + return value * 25.4 + else: + return value / 25.4 -- cgit