Excellon: fix first tests

This commit is contained in:
jaseg 2022-01-22 14:02:07 +01:00
parent 9966fa5ae6
commit b85e8b0065
8 changed files with 186 additions and 250 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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*%'

View file

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

View file

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

View file

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