Excellon: fix first tests
This commit is contained in:
parent
9966fa5ae6
commit
b85e8b0065
8 changed files with 186 additions and 250 deletions
|
|
@ -26,6 +26,13 @@ def strip_right(*args):
|
|||
args.pop()
|
||||
return args
|
||||
|
||||
def none_close(a, b):
|
||||
if a is None and b is None:
|
||||
return True
|
||||
elif a is not None and b is not None:
|
||||
return math.isclose(a, b)
|
||||
else:
|
||||
return False
|
||||
|
||||
class Length:
|
||||
def __init__(self, obj_type):
|
||||
|
|
@ -88,13 +95,13 @@ class ExcellonTool(Aperture):
|
|||
diameter : Length(float)
|
||||
plated : bool = None
|
||||
depth_offset : Length(float) = 0
|
||||
|
||||
|
||||
def primitives(self, x, y, unit=None):
|
||||
return [ gp.Circle(x, y, self.unit.convert_to(unit, self.diameter/2)) ]
|
||||
|
||||
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
|
||||
z_off = 'Z' + settings.write_excellon_value(self.depth_offset) if self.depth_offset is not None else ''
|
||||
return 'C' + settings.write_excellon_value(self.diameter) + z_off
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, ExcellonTool):
|
||||
|
|
@ -103,10 +110,10 @@ class ExcellonTool(Aperture):
|
|||
if not self.plated == other.plated:
|
||||
return False
|
||||
|
||||
if not math.isclose(self.depth_offset, self.unit(other.depth_offset, other.unit)):
|
||||
if not none_close(self.depth_offset, self.unit(other.depth_offset, other.unit)):
|
||||
return False
|
||||
|
||||
return math.isclose(self.diameter, self.unit(other.diameter, other.unit))
|
||||
return none_close(self.diameter, self.unit(other.diameter, other.unit))
|
||||
|
||||
def __str__(self):
|
||||
plated = '' if self.plated is None else (' plated' if self.plated else ' non-plated')
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ class FileSettings:
|
|||
`zeros='trailing'`
|
||||
'''
|
||||
notation : str = 'absolute'
|
||||
unit : LengthUnit = Inch
|
||||
unit : LengthUnit = MM
|
||||
angle_unit : str = 'degree'
|
||||
zeros : bool = None
|
||||
number_format : tuple = (2, 5)
|
||||
|
|
@ -52,7 +52,7 @@ class FileSettings:
|
|||
if len(value) != 2:
|
||||
raise ValueError(f'Number format must be a (integer, fractional) tuple of integers, not {value}')
|
||||
|
||||
if value[0] > 6 or value[1] > 7:
|
||||
if value != (None, None) and (value[0] > 6 or value[1] > 7):
|
||||
raise ValueError(f'Requested precision of {value} is too high. Only up to 6.7 digits are supported by spec.')
|
||||
|
||||
|
||||
|
|
@ -131,44 +131,92 @@ class FileSettings:
|
|||
|
||||
return sign + (num or '0')
|
||||
|
||||
def write_excellon_value(self, value, unit=None):
|
||||
if unit is not None:
|
||||
value = self.unit(value, unit)
|
||||
|
||||
integer_digits, decimal_digits = self.number_format
|
||||
if integer_digits is None:
|
||||
integer_digits = 2
|
||||
if decimal_digits is None:
|
||||
decimal_digits = 6
|
||||
|
||||
return format(value, f'0{integer_digits+decimal_digits+1}.{decimal_digits}f')
|
||||
|
||||
|
||||
class Tag:
|
||||
def __init__(self, name, children=None, root=False, **attrs):
|
||||
self.name, self.attrs = name, attrs
|
||||
self.children = children or []
|
||||
self.root = root
|
||||
|
||||
def __str__(self):
|
||||
prefix = '<?xml version="1.0" encoding="utf-8"?>\n' if self.root else ''
|
||||
opening = ' '.join([self.name] + [f'{key.replace("__", ":")}="{value}"' for key, value in self.attrs.items()])
|
||||
if self.children:
|
||||
children = '\n'.join(textwrap.indent(str(c), ' ') for c in self.children)
|
||||
return f'{prefix}<{opening}>\n{children}\n</{self.name}>'
|
||||
else:
|
||||
return f'{prefix}<{opening}/>'
|
||||
|
||||
|
||||
class CamFile:
|
||||
def __init__(self, filename=None, layer_name=None):
|
||||
self.filename = filename
|
||||
self.layer_name = layer_name
|
||||
self.import_settings = None
|
||||
self.objects = []
|
||||
|
||||
@property
|
||||
def bounds(self):
|
||||
""" File boundaries
|
||||
def to_svg(self, tag=Tag, margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, color='black'):
|
||||
|
||||
if force_bounds is None:
|
||||
(min_x, min_y), (max_x, max_y) = self.bounding_box(svg_unit, default=((0, 0), (0, 0)))
|
||||
else:
|
||||
(min_x, min_y), (max_x, max_y) = force_bounds
|
||||
min_x = svg_unit(min_x, arg_unit)
|
||||
min_y = svg_unit(min_y, arg_unit)
|
||||
max_x = svg_unit(max_x, arg_unit)
|
||||
max_y = svg_unit(max_y, arg_unit)
|
||||
|
||||
if margin:
|
||||
margin = svg_unit(margin, arg_unit)
|
||||
min_x -= margin
|
||||
min_y -= margin
|
||||
max_x += margin
|
||||
max_y += margin
|
||||
|
||||
w, h = max_x - min_x, max_y - min_y
|
||||
w = 1.0 if math.isclose(w, 0.0) else w
|
||||
h = 1.0 if math.isclose(h, 0.0) else h
|
||||
|
||||
primitives = [ prim.to_svg(tag, color) for obj in self.objects for prim in obj.to_primitives(unit=svg_unit) ]
|
||||
|
||||
# setup viewport transform flipping y axis
|
||||
xform = f'translate({min_x} {min_y+h}) scale(1 -1) translate({-min_x} {-min_y})'
|
||||
|
||||
svg_unit = 'in' if svg_unit == 'inch' else 'mm'
|
||||
# TODO export apertures as <uses> where reasonable.
|
||||
return tag('svg', [tag('g', primitives, transform=xform)],
|
||||
width=f'{w}{svg_unit}', height=f'{h}{svg_unit}',
|
||||
viewBox=f'{min_x} {min_y} {w} {h}',
|
||||
xmlns="http://www.w3.org/2000/svg", xmlns__xlink="http://www.w3.org/1999/xlink", root=True)
|
||||
|
||||
def size(self, unit=MM):
|
||||
(x0, y0), (x1, y1) = self.bounding_box(unit, default=((0, 0), (0, 0)))
|
||||
return (x1 - x0, y1 - y0)
|
||||
|
||||
def bounding_box(self, unit=MM, default=None):
|
||||
""" Calculate bounding box of file. Returns value given by 'default' argument when there are no graphical
|
||||
objects (default: None)
|
||||
"""
|
||||
pass
|
||||
bounds = [ p.bounding_box(unit) for p in self.objects ]
|
||||
if not bounds:
|
||||
return default
|
||||
|
||||
@property
|
||||
def bounding_box(self):
|
||||
pass
|
||||
min_x = min(x0 for (x0, y0), (x1, y1) in bounds)
|
||||
min_y = min(y0 for (x0, y0), (x1, y1) in bounds)
|
||||
max_x = max(x1 for (x0, y0), (x1, y1) in bounds)
|
||||
max_y = max(y1 for (x0, y0), (x1, y1) in bounds)
|
||||
|
||||
def render(self, ctx=None, invert=False, filename=None):
|
||||
""" Generate image of layer.
|
||||
return ((min_x, min_y), (max_x, max_y))
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ctx : :class:`GerberContext`
|
||||
GerberContext subclass used for rendering the image
|
||||
|
||||
filename : string <optional>
|
||||
If provided, save the rendered image to `filename`
|
||||
"""
|
||||
if ctx is None:
|
||||
from .render import GerberCairoContext
|
||||
ctx = GerberCairoContext()
|
||||
ctx.set_bounds(self.bounding_box)
|
||||
ctx.paint_background()
|
||||
ctx.invert = invert
|
||||
ctx.new_render_layer()
|
||||
for p in self.primitives:
|
||||
ctx.render(p)
|
||||
ctx.flatten()
|
||||
|
||||
if filename is not None:
|
||||
ctx.dump(filename)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import operator
|
|||
import warnings
|
||||
import functools
|
||||
import dataclasses
|
||||
import re
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
from collections import Counter
|
||||
|
|
@ -41,7 +42,7 @@ class ExcellonContext:
|
|||
def select_tool(self, tool):
|
||||
if self.current_tool != tool:
|
||||
self.current_tool = tool
|
||||
yield f'T{tools[tool]:02d}'
|
||||
yield f'T{self.tools[id(tool)]:02d}'
|
||||
|
||||
def drill_mode(self):
|
||||
if self.mode != ProgramState.DRILLING:
|
||||
|
|
@ -54,10 +55,10 @@ class ExcellonContext:
|
|||
if self.mode == ProgramState.ROUTING and (self.x, self.y) == (x, y):
|
||||
return # nothing to do
|
||||
|
||||
yield 'G00' + 'X' + self.settings.write_gerber_value(x) + 'Y' + self.settings.write_gerber_value(y)
|
||||
yield 'G00' + 'X' + self.settings.write_excellon_value(x) + 'Y' + self.settings.write_excellon_value(y)
|
||||
|
||||
def set_current_point(self, unit, x, y):
|
||||
self.current_point = self.unit(x, unit), self.unit(y, unit)
|
||||
self.current_point = self.settings.unit(x, unit), self.settings.unit(y, unit)
|
||||
|
||||
def parse_allegro_ncparam(data, settings=None):
|
||||
# This function parses data from allegro's nc_param.txt and ncdrill.log files. We have to parse these files because
|
||||
|
|
@ -70,7 +71,7 @@ def parse_allegro_ncparam(data, settings=None):
|
|||
|
||||
lz_supp, tz_supp = False, False
|
||||
for line in data.splitlines():
|
||||
line = re.sub('\s+', ' ', line.strip())
|
||||
line = re.sub(r'\s+', ' ', line.strip())
|
||||
|
||||
if (match := re.fullmatch(r'FORMAT ([0-9]+\.[0-9]+)', line)):
|
||||
x, _, y = match[1].partition('.')
|
||||
|
|
@ -165,7 +166,7 @@ class ExcellonFile(CamFile):
|
|||
# Prefer nc_param.txt over ncparam.log since the txt is the machine-readable one.
|
||||
if settings is None:
|
||||
for fn in 'nc_param.txt', 'ncdrill.log':
|
||||
if (param_file := filename.parent / fn).isfile():
|
||||
if (param_file := filename.parent / fn).is_file():
|
||||
settings = parse_allegro_ncparam(param_file.read_text())
|
||||
break
|
||||
|
||||
|
|
@ -176,7 +177,7 @@ class ExcellonFile(CamFile):
|
|||
parser = ExcellonParser(settings)
|
||||
parser._do_parse(data)
|
||||
return kls(objects=parser.objects, comments=parser.comments, import_settings=settings,
|
||||
generator=parser.generator, filename=filename, plated=plated)
|
||||
generator_hints=parser.generator_hints, filename=filename)
|
||||
|
||||
def _generate_statements(self, settings):
|
||||
|
||||
|
|
@ -190,21 +191,23 @@ class ExcellonFile(CamFile):
|
|||
yield 'METRIC' if settings.unit == MM else 'INCH'
|
||||
|
||||
# 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) }
|
||||
tool_map = { id(obj.tool): obj.tool for obj in self.objects }
|
||||
tools = sorted(tool_map.items(), key=lambda id_tool: (id_tool[1].plated, id_tool[1].diameter, id_tool[1].depth_offset))
|
||||
tools = { tool_id: index for index, (tool_id, _tool) in enumerate(tools, start=1) }
|
||||
|
||||
if max(tools) >= 100:
|
||||
if tools and max(tools.values()) >= 100:
|
||||
warnings.warn('More than 99 tools defined. Some programs may not like three-digit tool indices.', SyntaxWarning)
|
||||
|
||||
for tool, index in tools.items():
|
||||
yield f'T{index:02d}' + tool.to_xnc(settings)
|
||||
for tool_id, index in tools.items():
|
||||
yield f'T{index:02d}' + tool_map[tool_id].to_xnc(settings)
|
||||
|
||||
yield '%'
|
||||
|
||||
ctx = ExcellonContext(settings, tools)
|
||||
|
||||
# Export objects
|
||||
for obj in self.objects:
|
||||
obj.to_xnc(ctx)
|
||||
yield from obj.to_xnc(ctx)
|
||||
|
||||
yield 'M30'
|
||||
|
||||
|
|
@ -212,15 +215,25 @@ class ExcellonFile(CamFile):
|
|||
''' 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()
|
||||
if self.import_settings:
|
||||
settings = self.import_settings.copy()
|
||||
else:
|
||||
settings = FileSettings()
|
||||
settings.zeros = None
|
||||
settings.number_format = (3,5)
|
||||
return '\n'.join(self._generate_statements(settings))
|
||||
|
||||
def save(self, filename, settings=None):
|
||||
with open(filename, 'w') as f:
|
||||
f.write(self.to_excellon(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):
|
||||
if math.isclose(angle % (2*math.pi), 0):
|
||||
return
|
||||
|
||||
for obj in self.objects:
|
||||
obj.rotate(angle, cx, cy, unit=unit)
|
||||
|
||||
|
|
@ -300,7 +313,7 @@ class ProgramState(Enum):
|
|||
HEADER = 0
|
||||
DRILLING = 1
|
||||
ROUTING = 2
|
||||
FINISHED = 2
|
||||
FINISHED = 3
|
||||
|
||||
|
||||
class ExcellonParser(object):
|
||||
|
|
@ -320,6 +333,7 @@ class ExcellonParser(object):
|
|||
self.pos = 0, 0
|
||||
self.drill_down = False
|
||||
self.is_plated = None
|
||||
self.comments = []
|
||||
self.generator_hints = []
|
||||
|
||||
def _do_parse(self, data):
|
||||
|
|
@ -350,7 +364,7 @@ class ExcellonParser(object):
|
|||
exprs = RegexMatcher()
|
||||
|
||||
# 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]+')
|
||||
@exprs.match(r';T(?P<index1>[0-9]+) Holesize (?P<index2>[0-9]+)\. = (?P<diameter>[0-9/.]+) Tolerance = \+[0-9/.]+/-[0-9/.]+ (?P<plated>PLATED|NON_PLATED|OPTIONAL) (?P<unit>MILS|MM) Quantity = [0-9]+')
|
||||
def parse_allegro_tooldef(self, match):
|
||||
# NOTE: We ignore the given tolerances here since they are non-standard.
|
||||
self.program_state = ProgramState.HEADER # TODO is this needed? we need a test file.
|
||||
|
|
@ -390,7 +404,7 @@ class ExcellonParser(object):
|
|||
if (index := int(match['index'])) in self.tools:
|
||||
warnings.warn('Re-definition of tool index {index}, overwriting old definition.', SyntaxWarning)
|
||||
|
||||
tools[index] = tool
|
||||
self.tools[index] = tool
|
||||
self.generator_hints.append('easyeda')
|
||||
|
||||
@exprs.match('T([0-9]+)(([A-Z][.0-9]+)+)') # Tool definition: T** with at least one parameter
|
||||
|
|
@ -401,9 +415,10 @@ class ExcellonParser(object):
|
|||
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]) }
|
||||
params = { m[0]: self.settings.parse_gerber_value(m[1:]) for m in re.findall('[BCFHSTZ][.0-9]+', match[2]) }
|
||||
|
||||
self.tools[index] = ExcellonTool(diameter=params.get('C'), depth_offset=params.get('Z'), plated=self.is_plated)
|
||||
self.tools[index] = ExcellonTool(diameter=params.get('C'), depth_offset=params.get('Z'), plated=self.is_plated,
|
||||
unit=self.settings.unit)
|
||||
|
||||
if set(params.keys()) == set('TFSC'):
|
||||
self.generator_hints.append('target3001') # target files look like altium files without the comments
|
||||
|
|
@ -422,7 +437,7 @@ class ExcellonParser(object):
|
|||
|
||||
self.active_tool = self.tools[index]
|
||||
|
||||
coord = lambda name, key=None: f'(?P<{key or name}>{name}[+-]?[0-9]*\.?[0-9]*)?'
|
||||
coord = lambda name, key=None: fr'{name}(?P<{key or name}>[+-]?[0-9]*\.?[0-9]*)?'
|
||||
xy_coord = coord('X') + coord('Y')
|
||||
|
||||
@exprs.match(r'R(?P<count>[0-9]+)' + xy_coord)
|
||||
|
|
@ -442,15 +457,18 @@ class ExcellonParser(object):
|
|||
|
||||
self.objects.append(Flash(*self.pos, self.active_tool, unit=self.settings.unit))
|
||||
|
||||
def header_command(fun):
|
||||
@functools.wraps(fun)
|
||||
def wrapper(*args, **kwargs):
|
||||
if self.program_state is None:
|
||||
warnings.warn('Header statement found before start of header')
|
||||
elif self.program_state != ProgramState.HEADER:
|
||||
warnings.warn('Header statement found after end of header')
|
||||
fun(*args, **kwargs)
|
||||
return wrapper
|
||||
def header_command(name):
|
||||
def wrap(fun):
|
||||
@functools.wraps(fun)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
nonlocal name
|
||||
if self.program_state is None:
|
||||
warnings.warn(f'{name} header statement found before start of header')
|
||||
elif self.program_state != ProgramState.HEADER:
|
||||
warnings.warn(f'{name} header statement found after end of header')
|
||||
fun(self, *args, **kwargs)
|
||||
return wrapper
|
||||
return wrap
|
||||
|
||||
@exprs.match('M48')
|
||||
def handle_begin_header(self, match):
|
||||
|
|
@ -463,7 +481,7 @@ class ExcellonParser(object):
|
|||
self.program_state = ProgramState.HEADER
|
||||
|
||||
@exprs.match('M95')
|
||||
@header_command
|
||||
@header_command('M95')
|
||||
def handle_end_header(self, match):
|
||||
self.program_state = ProgramState.DRILLING
|
||||
|
||||
|
|
@ -489,28 +507,28 @@ class ExcellonParser(object):
|
|||
def handle_end_of_program(self, match):
|
||||
if self.program_state in (None, ProgramState.HEADER):
|
||||
warnings.warn('M30 statement found before end of header.', SyntaxWarning)
|
||||
self.program_state = FINISHED
|
||||
self.program_state = ProgramState.FINISHED
|
||||
# ignore.
|
||||
# TODO: maybe add warning if this is followed by other commands.
|
||||
|
||||
def do_move(self, match=None, x='X', y='Y'):
|
||||
x = settings.parse_gerber_value(match['X'])
|
||||
y = settings.parse_gerber_value(match['Y'])
|
||||
x = self.settings.parse_gerber_value(match['X'])
|
||||
y = self.settings.parse_gerber_value(match['Y'])
|
||||
|
||||
old_pos = self.pos
|
||||
|
||||
if self.settings.absolute:
|
||||
if x is not None:
|
||||
self.pos[0] = x
|
||||
self.pos = (x, self.pos[1])
|
||||
if y is not None:
|
||||
self.pos[1] = y
|
||||
self.pos = (self.pos[0], y)
|
||||
else: # incremental
|
||||
if x is not None:
|
||||
self.pos[0] += x
|
||||
self.pos = (self.pos[0]+x, self.pos[1])
|
||||
if y is not None:
|
||||
self.pos[1] += y
|
||||
self.pos = (self.pos[0], self.pos[1]+y)
|
||||
|
||||
return old_pos, new_pos
|
||||
return old_pos, self.pos
|
||||
|
||||
@exprs.match('G00' + xy_coord)
|
||||
def handle_start_routing(self, match):
|
||||
|
|
@ -554,8 +572,7 @@ class ExcellonParser(object):
|
|||
|
||||
start, end = self.do_move(match)
|
||||
|
||||
# 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):
|
||||
if self.program_state != ProgramState.ROUTING:
|
||||
return
|
||||
|
||||
if not self.drill_down or not (match['x'] or match['y']) or not self.ensure_active_tool():
|
||||
|
|
@ -601,16 +618,16 @@ class ExcellonParser(object):
|
|||
self.objects.append(Arc(*start, *end, i, j, True, self.active_tool, unit=self.settings.unit))
|
||||
|
||||
@exprs.match('M71|METRIC') # XNC uses "METRIC"
|
||||
@header_command
|
||||
@header_command('M71')
|
||||
def handle_metric_mode(self, match):
|
||||
self.settings.unit = MM
|
||||
|
||||
@exprs.match('M72|INCH') # XNC uses "INCH"
|
||||
@header_command
|
||||
@header_command('M72')
|
||||
def handle_inch_mode(self, match):
|
||||
self.settings.unit = Inch
|
||||
|
||||
@exprs.match('(METRIC|INCH)(,LZ|,TZ)?(0*\.0*)?')
|
||||
@exprs.match(r'(METRIC|INCH)(,LZ|,TZ)?(0*\.0*)?')
|
||||
def parse_easyeda_format(self, match):
|
||||
# geda likes to omit the LZ/TZ
|
||||
self.settings.unit = MM if match[1] == 'METRIC' else Inch
|
||||
|
|
@ -628,7 +645,7 @@ class ExcellonParser(object):
|
|||
self.generator_hints.append('easyeda')
|
||||
|
||||
@exprs.match('G90')
|
||||
@header_command
|
||||
@header_command('G90')
|
||||
def handle_absolute_mode(self, match):
|
||||
self.settings.notation = 'absolute'
|
||||
|
||||
|
|
@ -662,7 +679,16 @@ class ExcellonParser(object):
|
|||
|
||||
@exprs.match(xy_coord)
|
||||
def handle_naked_coordinate(self, match):
|
||||
self.do_interpolation(match)
|
||||
_start, end = self.do_move(match)
|
||||
|
||||
if not self.ensure_active_tool():
|
||||
return
|
||||
|
||||
# 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
|
||||
|
||||
self.objects.append(Flash(*end, self.active_tool, unit=self.settings.unit))
|
||||
|
||||
@exprs.match(r'; Format\s*: ([0-9]+\.[0-9]+) / (Absolute|Incremental) / (Inch|MM) / (Leading|Trailing)')
|
||||
def parse_siemens_format(self, match):
|
||||
|
|
@ -684,7 +710,7 @@ class ExcellonParser(object):
|
|||
@exprs.match(';FILE_FORMAT=([0-9]:[0-9])')
|
||||
def parse_altium_easyeda_number_format_comment(self, match):
|
||||
# Altium or newer EasyEDA exports
|
||||
x, _, y = fmt.partition(':')
|
||||
x, _, y = match[1].partition(':')
|
||||
self.settings.number_format = int(x), int(y)
|
||||
|
||||
@exprs.match(';Layer: (.*)')
|
||||
|
|
@ -710,7 +736,7 @@ class ExcellonParser(object):
|
|||
self.program_state = ProgramState.HEADER
|
||||
self.generator_hints.append('allegro')
|
||||
|
||||
@exprs.match(';GenerationSoftware,Autodesk,EAGLE,.*\*%')
|
||||
@exprs.match(r';GenerationSoftware,Autodesk,EAGLE,.*\*%')
|
||||
def parse_eagle_version_header(self, match):
|
||||
# NOTE: Only newer eagles export drills as XNC files. Older eagles produce an aperture-only gerber file called
|
||||
# "profile.gbr" instead.
|
||||
|
|
|
|||
|
|
@ -90,8 +90,8 @@ class Flash(GerberObject):
|
|||
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)
|
||||
x = ctx.settings.write_excellon_value(self.x, self.unit)
|
||||
y = ctx.settings.write_excellon_value(self.y, self.unit)
|
||||
yield f'X{x}Y{y}'
|
||||
|
||||
ctx.set_current_point(self.unit, self.x, self.y)
|
||||
|
|
|
|||
|
|
@ -53,33 +53,21 @@ def points_close(a, b):
|
|||
else:
|
||||
return math.isclose(a[0], b[0]) and math.isclose(a[1], b[1])
|
||||
|
||||
class Tag:
|
||||
def __init__(self, name, children=None, root=False, **attrs):
|
||||
self.name, self.attrs = name, attrs
|
||||
self.children = children or []
|
||||
self.root = root
|
||||
|
||||
def __str__(self):
|
||||
prefix = '<?xml version="1.0" encoding="utf-8"?>\n' if self.root else ''
|
||||
opening = ' '.join([self.name] + [f'{key.replace("__", ":")}="{value}"' for key, value in self.attrs.items()])
|
||||
if self.children:
|
||||
children = '\n'.join(textwrap.indent(str(c), ' ') for c in self.children)
|
||||
return f'{prefix}<{opening}>\n{children}\n</{self.name}>'
|
||||
else:
|
||||
return f'{prefix}<{opening}/>'
|
||||
|
||||
class GerberFile(CamFile):
|
||||
""" A class representing a single gerber file
|
||||
|
||||
The GerberFile class represents a single gerber file.
|
||||
"""
|
||||
|
||||
def __init__(self, filename=None):
|
||||
super().__init__(filename)
|
||||
def __init__(self, objects=None, comments=None, import_settings=None, filename=None, generator_hints=None,
|
||||
layer_hints=None):
|
||||
super().__init__(filename=filename)
|
||||
self.objects = objects or []
|
||||
self.comments = comments or []
|
||||
self.generator_hints = generator_hints or []
|
||||
self.layer_hints = layer_hints or []
|
||||
self.import_settings = import_settings
|
||||
self.apertures = [] # FIXME get rid of this? apertures are already in the objects.
|
||||
self.comments = []
|
||||
self.objects = []
|
||||
self.import_settings = None
|
||||
|
||||
def to_excellon(self):
|
||||
new_objs = []
|
||||
|
|
@ -96,40 +84,6 @@ class GerberFile(CamFile):
|
|||
|
||||
return ExcellonFile(objects=new_objs, comments=self.comments)
|
||||
|
||||
def to_svg(self, tag=Tag, margin=0, arg_unit=MM, svg_unit=MM, force_bounds=None, color='black'):
|
||||
|
||||
if force_bounds is None:
|
||||
(min_x, min_y), (max_x, max_y) = self.bounding_box(svg_unit, default=((0, 0), (0, 0)))
|
||||
else:
|
||||
(min_x, min_y), (max_x, max_y) = force_bounds
|
||||
min_x = svg_unit(min_x, arg_unit)
|
||||
min_y = svg_unit(min_y, arg_unit)
|
||||
max_x = svg_unit(max_x, arg_unit)
|
||||
max_y = svg_unit(max_y, arg_unit)
|
||||
|
||||
if margin:
|
||||
margin = svg_unit(margin, arg_unit)
|
||||
min_x -= margin
|
||||
min_y -= margin
|
||||
max_x += margin
|
||||
max_y += margin
|
||||
|
||||
w, h = max_x - min_x, max_y - min_y
|
||||
w = 1.0 if math.isclose(w, 0.0) else w
|
||||
h = 1.0 if math.isclose(h, 0.0) else h
|
||||
|
||||
primitives = [ prim.to_svg(tag, color) for obj in self.objects for prim in obj.to_primitives(unit=svg_unit) ]
|
||||
|
||||
# setup viewport transform flipping y axis
|
||||
xform = f'translate({min_x} {min_y+h}) scale(1 -1) translate({-min_x} {-min_y})'
|
||||
|
||||
svg_unit = 'in' if svg_unit == 'inch' else 'mm'
|
||||
# TODO export apertures as <uses> where reasonable.
|
||||
return tag('svg', [tag('g', primitives, transform=xform)],
|
||||
width=f'{w}{svg_unit}', height=f'{h}{svg_unit}',
|
||||
viewBox=f'{min_x} {min_y} {w} {h}',
|
||||
xmlns="http://www.w3.org/2000/svg", xmlns__xlink="http://www.w3.org/1999/xlink", root=True)
|
||||
|
||||
def merge(self, other):
|
||||
""" Merge other GerberFile into this one """
|
||||
if other is None:
|
||||
|
|
@ -216,25 +170,6 @@ class GerberFile(CamFile):
|
|||
GerberParser(obj, include_dir=enable_include_dir).parse(data)
|
||||
return obj
|
||||
|
||||
def size(self, unit=MM):
|
||||
(x0, y0), (x1, y1) = self.bounding_box(unit, default=((0, 0), (0, 0)))
|
||||
return (x1 - x0, y1 - y0)
|
||||
|
||||
def bounding_box(self, unit=MM, default=None):
|
||||
""" Calculate bounding box of file. Returns value given by 'default' argument when there are no graphical
|
||||
objects (default: None)
|
||||
"""
|
||||
bounds = [ p.bounding_box(unit) for p in self.objects ]
|
||||
if not bounds:
|
||||
return default
|
||||
|
||||
min_x = min(x0 for (x0, y0), (x1, y1) in bounds)
|
||||
min_y = min(y0 for (x0, y0), (x1, y1) in bounds)
|
||||
max_x = max(x1 for (x0, y0), (x1, y1) in bounds)
|
||||
max_y = max(y1 for (x0, y0), (x1, y1) in bounds)
|
||||
|
||||
return ((min_x, min_y), (max_x, max_y))
|
||||
|
||||
def generate_statements(self, settings, drop_comments=True):
|
||||
yield '%MOMM*%' if (settings.unit == 'mm') else '%MOIN*%'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,7 @@
|
|||
#! /usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Author: Jan Götte <code@jaseg.de>
|
||||
import os
|
||||
import re
|
||||
import math
|
||||
import functools
|
||||
import tempfile
|
||||
import shutil
|
||||
from argparse import Namespace
|
||||
from itertools import chain
|
||||
from pathlib import Path
|
||||
from contextlib import contextmanager
|
||||
from PIL import Image
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -24,7 +14,6 @@ from .utils import *
|
|||
REFERENCE_FILES = [
|
||||
'easyeda/Gerber_Drill_NPTH.DRL',
|
||||
'easyeda/Gerber_Drill_PTH.DRL',
|
||||
'allegro-2/MinnowMax_RevA1_IPC356A.ipc',
|
||||
'altium-composite-drill/NC Drill/LimeSDR-QPCIe_1v2-SlotHoles.TXT',
|
||||
'altium-composite-drill/NC Drill/LimeSDR-QPCIe_1v2-RoundHoles.TXT',
|
||||
'pcb-rnd/power-art.xln',
|
||||
|
|
@ -48,7 +37,7 @@ def test_round_trip(reference, tmpfile):
|
|||
|
||||
ExcellonFile.open(reference).save(tmp)
|
||||
|
||||
mean, _max, hist = excellon_difference(reference, tmp, diff_out=tmpfile('Difference', '.png'))
|
||||
mean, _max, hist = gerber_difference(reference, tmp, diff_out=tmpfile('Difference', '.png'))
|
||||
assert mean < 5e-5
|
||||
assert hist[9] == 0
|
||||
assert hist[3:].sum() < 5e-5*hist.size
|
||||
|
|
|
|||
|
|
@ -1,88 +1,16 @@
|
|||
#! /usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Author: Jan Götte <code@jaseg.de>
|
||||
import os
|
||||
import re
|
||||
import math
|
||||
import functools
|
||||
import tempfile
|
||||
import shutil
|
||||
from argparse import Namespace
|
||||
from itertools import chain
|
||||
from pathlib import Path
|
||||
from contextlib import contextmanager
|
||||
from PIL import Image
|
||||
|
||||
from PIL import Image
|
||||
import pytest
|
||||
|
||||
from ..rs274x import GerberFile
|
||||
from ..cam import FileSettings
|
||||
|
||||
from .image_support import *
|
||||
|
||||
|
||||
deg_to_rad = lambda a: a/180 * math.pi
|
||||
|
||||
fail_dir = Path('gerbonara_test_failures')
|
||||
reference_path = lambda reference: Path(__file__).parent / 'resources' / reference
|
||||
|
||||
def path_test_name(request):
|
||||
""" Create a slug suitable for use in file names from the test's nodeid """
|
||||
module, _, test_name = request.node.nodeid.rpartition('::')
|
||||
_test, _, test_name = test_name.partition('_')
|
||||
test_name, _, _ext = test_name.partition('.')
|
||||
return re.sub(r'[^\w\d]', '_', test_name)
|
||||
|
||||
@pytest.fixture
|
||||
def print_on_error(request):
|
||||
messages = []
|
||||
|
||||
def register_print(*args, sep=' ', end='\n'):
|
||||
nonlocal messages
|
||||
messages.append(sep.join(str(arg) for arg in args) + end)
|
||||
|
||||
yield register_print
|
||||
|
||||
if request.node.rep_call.failed:
|
||||
for msg in messages:
|
||||
print(msg, end='')
|
||||
|
||||
@pytest.fixture
|
||||
def tmpfile(request):
|
||||
registered = []
|
||||
|
||||
def register_tempfile(name, suffix):
|
||||
nonlocal registered
|
||||
f = tempfile.NamedTemporaryFile(suffix=suffix)
|
||||
registered.append((name, suffix, f))
|
||||
return Path(f.name)
|
||||
|
||||
yield register_tempfile
|
||||
|
||||
if request.node.rep_call.failed:
|
||||
fail_dir.mkdir(exist_ok=True)
|
||||
test_name = path_test_name(request)
|
||||
for name, suffix, tmp in registered:
|
||||
slug = re.sub(r'[^\w\d]+', '_', name.lower())
|
||||
perm_path = fail_dir / f'failure_{test_name}_{slug}{suffix}'
|
||||
shutil.copy(tmp.name, perm_path)
|
||||
print(f'{name} saved to {perm_path}')
|
||||
|
||||
for _name, _suffix, tmp in registered:
|
||||
tmp.close()
|
||||
|
||||
@pytest.fixture
|
||||
def reference(request, print_on_error):
|
||||
ref = reference_path(request.param)
|
||||
yield ref
|
||||
print_on_error(f'Reference file: {ref}')
|
||||
|
||||
def filter_syntax_warnings(fun):
|
||||
a = pytest.mark.filterwarnings('ignore:Deprecated.*statement found.*:DeprecationWarning')
|
||||
b = pytest.mark.filterwarnings('ignore::SyntaxWarning')
|
||||
return a(b(fun))
|
||||
|
||||
to_gerbv_svg_units = lambda val, unit='mm': val*72 if unit == 'inch' else val/25.4*72
|
||||
from .utils import *
|
||||
|
||||
REFERENCE_FILES = [ l.strip() for l in '''
|
||||
board_outline.GKO
|
||||
|
|
@ -179,7 +107,7 @@ def test_rotation(reference, angle, tmpfile):
|
|||
tmp_gbr = tmpfile('Output gerber', '.gbr')
|
||||
|
||||
f = GerberFile.open(reference)
|
||||
f.rotate(deg_to_rad(angle))
|
||||
f.rotate(math.radians(angle))
|
||||
f.save(tmp_gbr)
|
||||
|
||||
cx, cy = 0, to_gerbv_svg_units(10, unit='inch')
|
||||
|
|
@ -200,7 +128,7 @@ def test_rotation_center(reference, angle, center, tmpfile):
|
|||
tmp_gbr = tmpfile('Output gerber', '.gbr')
|
||||
|
||||
f = GerberFile.open(reference)
|
||||
f.rotate(deg_to_rad(angle), center=center)
|
||||
f.rotate(math.radians(angle), center=center)
|
||||
f.save(tmp_gbr)
|
||||
|
||||
# calculate circle center in SVG coordinates
|
||||
|
|
@ -243,7 +171,7 @@ def test_combined(reference, angle, center, offset, tmpfile):
|
|||
tmp_gbr = tmpfile('Output gerber', '.gbr')
|
||||
|
||||
f = GerberFile.open(reference)
|
||||
f.rotate(deg_to_rad(angle), center=center)
|
||||
f.rotate(math.radians(angle), center=center)
|
||||
f.offset(*offset)
|
||||
f.save(tmp_gbr, settings=FileSettings(unit=f.unit, number_format=(4,7)))
|
||||
|
||||
|
|
@ -284,7 +212,7 @@ def test_compositing(file_a, file_b, angle, offset, tmpfile, print_on_error):
|
|||
|
||||
ax, ay, bx, by = offset
|
||||
grb_a = GerberFile.open(ref_a)
|
||||
grb_a.rotate(deg_to_rad(angle))
|
||||
grb_a.rotate(math.radians(angle))
|
||||
grb_a.offset(ax, ay)
|
||||
|
||||
grb_b = GerberFile.open(ref_b)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ files.
|
|||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from enum import Enum
|
||||
from math import radians, sin, cos, sqrt, atan2, pi
|
||||
|
||||
|
|
@ -41,7 +42,9 @@ class RegexMatcher:
|
|||
def handle(self, inst, line):
|
||||
for regex, handler in self.mapping.items():
|
||||
if (match := re.fullmatch(regex, line)):
|
||||
handler(match)
|
||||
#print(' handler', handler.__name__)
|
||||
handler(inst, match)
|
||||
break
|
||||
|
||||
class LengthUnit:
|
||||
def __init__(self, name, shorthand, this_in_mm):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue