From b85e8b0065c1b90159970ed8139f0747e953eb3f Mon Sep 17 00:00:00 2001 From: jaseg Date: Sat, 22 Jan 2022 14:02:07 +0100 Subject: Excellon: fix first tests --- gerbonara/gerber/apertures.py | 17 +++-- gerbonara/gerber/cam.py | 114 +++++++++++++++++++++--------- gerbonara/gerber/excellon.py | 120 +++++++++++++++++++------------- gerbonara/gerber/graphic_objects.py | 4 +- gerbonara/gerber/rs274x.py | 81 +++------------------ gerbonara/gerber/tests/test_excellon.py | 13 +--- gerbonara/gerber/tests/test_rs274x.py | 84 ++-------------------- gerbonara/gerber/utils.py | 5 +- 8 files changed, 187 insertions(+), 251 deletions(-) (limited to 'gerbonara/gerber') diff --git a/gerbonara/gerber/apertures.py b/gerbonara/gerber/apertures.py index 3d3c074..0f723d4 100644 --- a/gerbonara/gerber/apertures.py +++ b/gerbonara/gerber/apertures.py @@ -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') diff --git a/gerbonara/gerber/cam.py b/gerbonara/gerber/cam.py index 57ac000..bbcd042 100644 --- a/gerbonara/gerber/cam.py +++ b/gerbonara/gerber/cam.py @@ -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 = '\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' + 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 - - @property - def bounds(self): - """ File boundaries + self.objects = [] + + 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 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 - - @property - def bounding_box(self): - pass + bounds = [ p.bounding_box(unit) for p in self.objects ] + if not bounds: + return default - def render(self, ctx=None, invert=False, filename=None): - """ Generate image of layer. + 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) - Parameters - ---------- - ctx : :class:`GerberContext` - GerberContext subclass used for rendering the image + return ((min_x, min_y), (max_x, max_y)) - filename : string - 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) diff --git a/gerbonara/gerber/excellon.py b/gerbonara/gerber/excellon.py index 6cc5e69..a42a667 100755 --- a/gerbonara/gerber/excellon.py +++ b/gerbonara/gerber/excellon.py @@ -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[0-9]+) Holesize (?P[0-9]+)\. = (?P[0-9/.]+) Tolerance = \+[0-9/.]+/-[0-9/.]+ (?PPLATED|NON_PLATED|OPTIONAL) (?PMILS|MM) Quantity = [0-9]+') + @exprs.match(r';T(?P[0-9]+) Holesize (?P[0-9]+)\. = (?P[0-9/.]+) Tolerance = \+[0-9/.]+/-[0-9/.]+ (?PPLATED|NON_PLATED|OPTIONAL) (?PMILS|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[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. diff --git a/gerbonara/gerber/graphic_objects.py b/gerbonara/gerber/graphic_objects.py index c626ba3..b42a01c 100644 --- a/gerbonara/gerber/graphic_objects.py +++ b/gerbonara/gerber/graphic_objects.py @@ -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) diff --git a/gerbonara/gerber/rs274x.py b/gerbonara/gerber/rs274x.py index 80605a5..e986225 100644 --- a/gerbonara/gerber/rs274x.py +++ b/gerbonara/gerber/rs274x.py @@ -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 = '\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' - 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 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*%' diff --git a/gerbonara/gerber/tests/test_excellon.py b/gerbonara/gerber/tests/test_excellon.py index 545bec9..f8d54ed 100644 --- a/gerbonara/gerber/tests/test_excellon.py +++ b/gerbonara/gerber/tests/test_excellon.py @@ -1,17 +1,7 @@ #! /usr/bin/env python # -*- coding: utf-8 -*- # Author: Jan Götte -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 diff --git a/gerbonara/gerber/tests/test_rs274x.py b/gerbonara/gerber/tests/test_rs274x.py index 68c5367..2256525 100644 --- a/gerbonara/gerber/tests/test_rs274x.py +++ b/gerbonara/gerber/tests/test_rs274x.py @@ -1,88 +1,16 @@ #! /usr/bin/env python # -*- coding: utf-8 -*- # Author: Jan Götte -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) diff --git a/gerbonara/gerber/utils.py b/gerbonara/gerber/utils.py index d1045ad..30b03c9 100644 --- a/gerbonara/gerber/utils.py +++ b/gerbonara/gerber/utils.py @@ -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): -- cgit