Excellon WIP

This commit is contained in:
jaseg 2022-01-16 21:59:24 +01:00
parent d644661fb0
commit 336a18fb49
10 changed files with 464 additions and 1553 deletions

View file

@ -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):

View file

@ -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

View file

@ -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())

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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))

View file

@ -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)

View file

@ -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'):

View file

@ -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