Excellon WIP
This commit is contained in:
parent
d644661fb0
commit
336a18fb49
10 changed files with 464 additions and 1553 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
filename : string, optional
|
||||
string containing the filename of the data source
|
||||
|
||||
tools: dict (optional)
|
||||
externally defined tools
|
||||
|
||||
Returns
|
||||
-------
|
||||
file : :class:`gerber.excellon.ExcellonFile`
|
||||
An ExcellonFile created from the specified file.
|
||||
|
||||
"""
|
||||
# File object should use settings from source file by default.
|
||||
if not settings:
|
||||
settings = FileSettings(**detect_excellon_format(data))
|
||||
return ExcellonParser(settings, tools).parse_raw(data, filename)
|
||||
|
||||
|
||||
class DrillHit(object):
|
||||
"""Drill feature that is a single drill hole.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
tool : ExcellonTool
|
||||
Tool to drill the hole. Defines the size of the hole that is generated.
|
||||
position : tuple(float, float)
|
||||
Center position of the drill.
|
||||
|
||||
"""
|
||||
def __init__(self, tool, position):
|
||||
self.tool = tool
|
||||
self.position = position
|
||||
|
||||
@property
|
||||
def bounding_box(self):
|
||||
position = self.position
|
||||
radius = self.tool.diameter / 2.
|
||||
|
||||
min_x = position[0] - radius
|
||||
max_x = position[0] + radius
|
||||
min_y = position[1] - radius
|
||||
max_y = position[1] + radius
|
||||
return ((min_x, max_x), (min_y, max_y))
|
||||
|
||||
def offset(self, x_offset=0, y_offset=0):
|
||||
self.position = tuple(map(operator.add, self.position, (x_offset, y_offset)))
|
||||
|
||||
def __str__(self):
|
||||
return 'Hit (%f, %f) {%s}' % (self.position[0], self.position[1], self.tool)
|
||||
|
||||
class DrillSlot(object):
|
||||
"""
|
||||
A slot is created between two points. The way the slot is created depends on the statement used to create it
|
||||
"""
|
||||
|
||||
TYPE_ROUT = 1
|
||||
TYPE_G85 = 2
|
||||
|
||||
def __init__(self, tool, start, end, slot_type):
|
||||
self.tool = tool
|
||||
self.start = start
|
||||
self.end = end
|
||||
self.slot_type = slot_type
|
||||
|
||||
@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)))
|
||||
def set_current_point(self, unit, x, y):
|
||||
self.current_point = self.unit.from(unit, x), self.unit.from(unit, y)
|
||||
|
||||
|
||||
class ExcellonFile(CamFile):
|
||||
""" A class representing a single excellon file
|
||||
def __init__(self, filename=None)
|
||||
super().__init__(filename=filename)
|
||||
self.objects = []
|
||||
self.comments = []
|
||||
self.import_settings = None
|
||||
|
||||
The ExcellonFile class represents a single excellon file.
|
||||
def _generate_statements(self, settings):
|
||||
|
||||
http://www.excellon.com/manuals/program.htm
|
||||
(archived version at https://web.archive.org/web/20150920001043/http://www.excellon.com/manuals/program.htm)
|
||||
yield '; XNC file generated by gerbonara'
|
||||
if self.comments:
|
||||
yield '; Comments found in original file:'
|
||||
for comment in self.comments:
|
||||
yield ';' + comment
|
||||
|
||||
Parameters
|
||||
----------
|
||||
tools : list
|
||||
list of gerber file statements
|
||||
yield 'M48'
|
||||
yield 'METRIC' if settings.unit == 'mm' else 'INCH'
|
||||
|
||||
hits : list of tuples
|
||||
list of drill hits as (<Tool>, (x, y))
|
||||
# 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) }
|
||||
|
||||
settings : dict
|
||||
Dictionary of gerber file settings
|
||||
if max(tools) >= 100:
|
||||
warnings.warn('More than 99 tools defined. Some programs may not like three-digit tool indices.', SyntaxWarning)
|
||||
|
||||
filename : string
|
||||
Filename of the source gerber file
|
||||
for tool, index in tools.items():
|
||||
yield f'T{index:02d}' + tool.to_xnc(settings)
|
||||
|
||||
Attributes
|
||||
----------
|
||||
units : string
|
||||
either 'inch' or 'metric'.
|
||||
yield '%'
|
||||
|
||||
"""
|
||||
# Export objects
|
||||
for obj in self.objects:
|
||||
obj.to_xnc(ctx)
|
||||
|
||||
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
|
||||
yield 'M30'
|
||||
|
||||
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))
|
||||
|
||||
def offset(self, x=0, y=0, unit=MM):
|
||||
self.objects = [ obj.with_offset(x, y, unit) for obj in self.objects ]
|
||||
|
||||
def rotate(self, angle, cx=0, cy=0, unit=MM):
|
||||
for obj in self.objects:
|
||||
obj.rotate(angle, cx, cy, unit=unit)
|
||||
|
||||
@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 has_mixed_plating(self):
|
||||
return len(set(obj.plated for obj in self.objects)) > 1
|
||||
|
||||
@property
|
||||
def is_plated(self):
|
||||
return all(obj.plated for obj in self.objects)
|
||||
|
||||
@property
|
||||
def bounding_box(self):
|
||||
xmin = ymin = 100000000000
|
||||
xmax = ymax = -100000000000
|
||||
for hit in self.hits:
|
||||
bbox = hit.bounding_box
|
||||
xmin = min(bbox[0][0], xmin)
|
||||
xmax = max(bbox[0][1], xmax)
|
||||
ymin = min(bbox[1][0], ymin)
|
||||
ymax = max(bbox[1][1], ymax)
|
||||
return ((xmin, xmax), (ymin, ymax))
|
||||
def is_nonplated(self):
|
||||
return not any(obj.plated for obj in self.objects)
|
||||
|
||||
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 empty(self):
|
||||
return self.objects.empty()
|
||||
|
||||
def write(self, filename=None):
|
||||
filename = filename if filename is not None else self.filename
|
||||
with open(filename, 'w') as f:
|
||||
def __len__(self):
|
||||
return len(self.objects)
|
||||
|
||||
# Copy the header verbatim
|
||||
for statement in self.statements:
|
||||
if not isinstance(statement, ToolSelectionStmt):
|
||||
f.write(statement.to_excellon(self.settings) + '\n')
|
||||
else:
|
||||
break
|
||||
def split_by_plating(self):
|
||||
plated, nonplated = ExcellonFile(self.filename), ExcellonFile(self.filename)
|
||||
|
||||
# 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')
|
||||
plated.comments = self.comments.copy()
|
||||
plated.import_settings = self.import_settings.copy()
|
||||
plated.objects = [ obj for obj in self.objects if obj.plated ]
|
||||
|
||||
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)
|
||||
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 path_length(self, tool_number=None):
|
||||
""" Return the path length for a given tool
|
||||
return nonplated, plated
|
||||
|
||||
def path_lengths(self, unit):
|
||||
""" Calculate path lengths per tool.
|
||||
|
||||
Returns: dict { tool: float(path length) }
|
||||
|
||||
This function only sums actual cut lengths, and ignores travel lengths that the tool is doing without cutting to
|
||||
get from one object to another. Travel lengths depend on the CAM program's path planning, which highly depends
|
||||
on panelization and other factors. Additionally, an EDA tool will not even attempt to minimize travel distance
|
||||
as that's not its job.
|
||||
"""
|
||||
lengths = {}
|
||||
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
|
||||
tool = None
|
||||
for obj in sorted(self.objects, key=lambda obj: obj.tool):
|
||||
if tool != obj.tool:
|
||||
tool = obj.tool
|
||||
lengths[tool] = 0
|
||||
|
||||
if tool_number is None:
|
||||
return lengths
|
||||
else:
|
||||
return lengths.get(tool_number)
|
||||
lengths[tool] += obj.curve_length(unit)
|
||||
return lengths
|
||||
|
||||
def hit_count(self, tool_number=None):
|
||||
counts = {}
|
||||
for tool in iter(self.tools.values()):
|
||||
counts[tool.number] = tool.hit_count
|
||||
if tool_number is None:
|
||||
return counts
|
||||
else:
|
||||
return counts.get(tool_number)
|
||||
|
||||
def update_tool(self, tool_number, **kwargs):
|
||||
""" Change parameters of a tool
|
||||
"""
|
||||
if kwargs.get('feed_rate') is not None:
|
||||
self.tools[tool_number].feed_rate = kwargs.get('feed_rate')
|
||||
if kwargs.get('retract_rate') is not None:
|
||||
self.tools[tool_number].retract_rate = kwargs.get('retract_rate')
|
||||
if kwargs.get('rpm') is not None:
|
||||
self.tools[tool_number].rpm = kwargs.get('rpm')
|
||||
if kwargs.get('diameter') is not None:
|
||||
self.tools[tool_number].diameter = kwargs.get('diameter')
|
||||
if kwargs.get('max_hit_count') is not None:
|
||||
self.tools[tool_number].max_hit_count = kwargs.get('max_hit_count')
|
||||
if kwargs.get('depth_offset') is not None:
|
||||
self.tools[tool_number].depth_offset = kwargs.get('depth_offset')
|
||||
# Update drill hits
|
||||
newtool = self.tools[tool_number]
|
||||
for hit in self.hits:
|
||||
if hit.tool.number == newtool.number:
|
||||
hit.tool = newtool
|
||||
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)
|
||||
clockwise = (self.interpolation_mode == InterpMode.CIRCULAR_CW)
|
||||
|
||||
if a:
|
||||
if i or j:
|
||||
warnings.warn('Arc without both radius and center specified.', SyntaxWarning)
|
||||
|
||||
if self.drill_down:
|
||||
if not self.ensure_active_tool():
|
||||
return
|
||||
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)
|
||||
self.do_interpolation(match)
|
||||
|
||||
# FIXME handle arcs
|
||||
@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)
|
||||
|
||||
# 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
|
||||
@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
|
||||
|
||||
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()
|
||||
|
||||
else:
|
||||
warnings.warn('Found unexpected coordinate', SyntaxWarning)
|
||||
@exprs.match(';(.*)')
|
||||
def parse_comment(self, match)
|
||||
target.comments.append(match[1].strip())
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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'):
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue