summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjaseg <git@jaseg.de>2022-01-16 21:59:24 +0100
committerjaseg <git@jaseg.de>2022-01-16 21:59:24 +0100
commit336a18fb493c79824323a59865083a0037a4a2f4 (patch)
tree3e1e0db5f821cf52c32f70a4b38fac77c5a99c8c
parentd644661fb04d40a3e95dd604f8cc13641bab263b (diff)
downloadgerbonara-336a18fb493c79824323a59865083a0037a4a2f4.tar.gz
gerbonara-336a18fb493c79824323a59865083a0037a4a2f4.tar.bz2
gerbonara-336a18fb493c79824323a59865083a0037a4a2f4.zip
Excellon WIP
-rw-r--r--gerbonara/gerber/apertures.py132
-rw-r--r--gerbonara/gerber/cam.py5
-rwxr-xr-xgerbonara/gerber/excellon.py648
-rw-r--r--gerbonara/gerber/excellon_statements.py871
-rw-r--r--gerbonara/gerber/excellon_tool.py190
-rw-r--r--gerbonara/gerber/gerber_statements.py2
-rw-r--r--gerbonara/gerber/graphic_objects.py123
-rw-r--r--gerbonara/gerber/graphic_primitives.py1
-rw-r--r--gerbonara/gerber/rs274x.py3
-rw-r--r--gerbonara/gerber/utils.py42
10 files changed, 464 insertions, 1553 deletions
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'<Excellon Tool d={self.diameter:.3f}{plated}{z_off}>'
+
+ 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'<circle aperture d={self.diameter:.3}>'
@@ -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'<rect aperture {self.w:.3}x{self.h:.3}>'
@@ -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'<obround aperture {self.w:.3}x{self.h:.3}>'
@@ -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 (<Tool>, (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(';(?P<comment>FILE_FORMAT=(?P<format>[0-9]:[0-9])|TYPE=(?P<plating>PLATED|NON_PLATED)|(?P<header>HEADER:)|.*(?P<tooldef> Holesize)|.*)')
- def parse_comment(self, match):
+ # NOTE: These must be kept before the generic comment handler at the end of this class so they match first.
+ @exprs.match(';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.
- # 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<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:
+ 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<count>[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('(?P<mode>G01|G02|G03)' + xy_coord + aij_coord):
- def handle_linear_mode(self, match)
+ @exprs.match('(?P<mode>G01|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<count>[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 <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.
-"""
-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 = '<ExcellonTool %%02d: %%%d.%dg%%s dia.>' % 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 '<Coordinate Statement: %s>' % coord_str
-
-
-class RepeatHoleStmt(ExcellonStatement):
-
- @classmethod
- def from_excellon(cls, line, settings, **kwargs):
- match = re.compile(r'R(?P<rcount>[0-9]*)X?(?P<xdelta>[+\-]?\d*\.?\d*)?Y?'
- '(?P<ydelta>[+\-]?\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 '<Repeat Hole: %d times, offset X: %g Y: %g>' % (
- 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<x>\d*\.?\d*)?Y?'
- '(?P<y>\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 "<Unknown Statement: %s>" % 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 '<Slot Statement: %s to %s>' % (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 <garret@ficksworkshop.com>
-
-# 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<size>[0-9/.]+)\s+(?P<plated>P|N)\s+T(?P<toolid>[0-9]{2})\s+(?P<xtol>[0-9/.]+)\s+(?P<ytol>[0-9/.]+)')
- allegro_comment_mils = re.compile('Holesize (?P<toolid>[0-9]{1,2})\. = (?P<size>[0-9/.]+) Tolerance = \+(?P<xtol>[0-9/.]+)/-(?P<ytol>[0-9/.]+) (?P<plated>(PLATED)|(NON_PLATED)|(OPTIONAL)) MILS Quantity = [0-9]+')
- allegro2_comment_mils = re.compile('T(?P<toolid>[0-9]{1,2}) Holesize (?P<toolid2>[0-9]{1,2})\. = (?P<size>[0-9/.]+) Tolerance = \+(?P<xtol>[0-9/.]+)/-(?P<ytol>[0-9/.]+) (?P<plated>(PLATED)|(NON_PLATED)|(OPTIONAL)) MILS Quantity = [0-9]+')
- allegro_comment_mm = re.compile('Holesize (?P<toolid>[0-9]{1,2})\. = (?P<size>[0-9/.]+) Tolerance = \+(?P<xtol>[0-9/.]+)/-(?P<ytol>[0-9/.]+) (?P<plated>(PLATED)|(NON_PLATED)|(OPTIONAL)) MM Quantity = [0-9]+')
- allegro2_comment_mm = re.compile('T(?P<toolid>[0-9]{1,2}) Holesize (?P<toolid2>[0-9]{1,2})\. = (?P<size>[0-9/.]+) Tolerance = \+(?P<xtol>[0-9/.]+)/-(?P<ytol>[0-9/.]+) (?P<plated>(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