From a1c1d6d971257f03f1926db9dc44e659d2773d2d Mon Sep 17 00:00:00 2001 From: jaseg Date: Mon, 24 Jan 2022 14:19:01 +0100 Subject: Add more tests --- gerbonara/gerber/apertures.py | 5 +- gerbonara/gerber/excellon.py | 73 ++++++++++++---------- gerbonara/gerber/graphic_objects.py | 12 ++-- gerbonara/gerber/rs274x.py | 105 +++++++++++++++++++------------- gerbonara/gerber/tests/image_support.py | 1 + gerbonara/gerber/tests/test_excellon.py | 22 ++++++- gerbonara/gerber/tests/test_rs274x.py | 11 ++++ 7 files changed, 147 insertions(+), 82 deletions(-) diff --git a/gerbonara/gerber/apertures.py b/gerbonara/gerber/apertures.py index d288a2a..ab4a1a1 100644 --- a/gerbonara/gerber/apertures.py +++ b/gerbonara/gerber/apertures.py @@ -77,7 +77,10 @@ class Aperture: unit = settings.unit if settings else None actual_inst = self._rotated() params = 'X'.join(f'{float(par):.4}' for par in actual_inst.params(unit) if par is not None) - return ','.join((actual_inst.gerber_shape_code, params)) + if params: + return f'{actual_inst.gerber_shape_code},{params}' + else: + return actual_inst.gerber_shape_code def __eq__(self, other): # We need to choose some unit here. diff --git a/gerbonara/gerber/excellon.py b/gerbonara/gerber/excellon.py index 02e63d7..3931660 100755 --- a/gerbonara/gerber/excellon.py +++ b/gerbonara/gerber/excellon.py @@ -52,10 +52,14 @@ class ExcellonContext: def route_mode(self, unit, x, y): x, y = self.settings.unit(x, unit), self.settings.unit(y, unit) - if self.mode == ProgramState.ROUTING and (self.x, self.y) == (x, y): - return # nothing to do + if self.mode == ProgramState.ROUTING: + if (self.x, self.y) == (x, y): + return # nothing to do + else: + yield 'M16' # drill up yield 'G00' + 'X' + self.settings.write_excellon_value(x) + 'Y' + self.settings.write_excellon_value(y) + yield 'M15' # drill down def set_current_point(self, unit, x, y): self.current_point = self.settings.unit(x, unit), self.settings.unit(y, unit) @@ -179,13 +183,13 @@ class ExcellonFile(CamFile): return kls(objects=parser.objects, comments=parser.comments, import_settings=settings, generator_hints=parser.generator_hints, filename=filename) - def _generate_statements(self, settings): + def _generate_statements(self, settings, drop_comments=True): yield '; XNC file generated by gerbonara' - if self.comments: + if self.comments and not drop_comments: yield '; Comments found in original file:' - for comment in self.comments: - yield ';' + comment + for comment in self.comments: + yield ';' + comment yield 'M48' yield 'METRIC' if settings.unit == MM else 'INCH' @@ -219,7 +223,7 @@ class ExcellonFile(CamFile): yield 'M30' - def to_excellon(self, settings=None): + def to_excellon(self, settings=None, drop_comments=True): ''' Export to Excellon format. This function always generates XNC, which is a well-defined subset of Excellon. ''' if settings is None: @@ -229,11 +233,11 @@ class ExcellonFile(CamFile): settings = FileSettings() settings.zeros = None settings.number_format = (3,5) - return '\n'.join(self._generate_statements(settings)) + return '\n'.join(self._generate_statements(settings, drop_comments=drop_comments)) - def save(self, filename, settings=None): + def save(self, filename, settings=None, drop_comments=True): with open(filename, 'w') as f: - f.write(self.to_excellon(settings)) + f.write(self.to_excellon(settings, drop_comments=drop_comments)) def offset(self, x=0, y=0, unit=MM): self.objects = [ obj.with_offset(x, y, unit) for obj in self.objects ] @@ -346,14 +350,20 @@ class ExcellonParser(object): self.is_plated = None self.comments = [] self.generator_hints = [] + self.lineno = None + self.filename = None + + def warn(self, msg): + warnings.warn(f'{self.filename}:{self.lineno} "{self.line}": {msg}', SyntaxWarning) def do_parse(self, data, filename=None): # filename arg is for error messages - filename = filename or '' + self.filename = filename = filename or '' leftover = None for lineno, line in enumerate(data.splitlines(), start=1): line = line.strip() + self.lineno, self.line = lineno, line # for warnings if not line: continue @@ -361,7 +371,7 @@ class ExcellonParser(object): # Coordinates of G00 and G01 may be on the next line if line == 'G00' or line == 'G01': if leftover: - warnings.warn('Two consecutive G00/G01 commands without coordinates. Ignoring first.', SyntaxWarning) + self.warn('Two consecutive G00/G01 commands without coordinates. Ignoring first.') leftover = line continue @@ -370,10 +380,11 @@ class ExcellonParser(object): leftover = None if line and self.program_state == ProgramState.FINISHED: - warnings.warn('Commands found following end of program statement.', SyntaxWarning) + self.warn('Commands found following end of program statement.') # TODO check first command in file is "start of header" command. try: + #print(f'{lineno} "{line}"', end=' ') if not self.exprs.handle(self, line): raise ValueError('Unknown excellon statement:', line) except Exception as e: @@ -392,7 +403,7 @@ class ExcellonParser(object): raise SyntaxError('BUG: Allegro excellon tool def has mismatching tool indices. Please file a bug report on our issue tracker and provide this file!') if index in self.tools: - warnings.warn('Re-definition of tool index {index}, overwriting old definition.', SyntaxWarning) + self.warn('Re-definition of tool index {index}, overwriting old definition.') # 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. @@ -407,9 +418,9 @@ class ExcellonParser(object): unit = MM if unit != self.settings.unit: - warnings.warn('Allegro Excellon drill file tool definitions in {unit.name}, but file parameters say the ' + self.warn('Allegro Excellon drill file tool definitions in {unit.name}, but file parameters say the ' 'file should be in {settings.unit.name}. Please double-check that this is correct, and if it is, ' - 'please raise an issue on our issue tracker.', SyntaxWarning) + 'please raise an issue on our issue tracker.') self.tools[index] = ExcellonTool(diameter=diameter, plated=is_plated, unit=unit) @@ -420,7 +431,7 @@ class ExcellonParser(object): 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) + self.warn('Re-definition of tool index {index}, overwriting old definition.') self.tools[index] = tool self.generator_hints.append('easyeda') @@ -431,7 +442,7 @@ class ExcellonParser(object): # 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) + self.warn('Re-definition of tool index {index}, overwriting old definition.') params = { m[0]: self.settings.parse_gerber_value(m[1:]) for m in re.findall('[BCFHSTZ][.0-9]+', match[2]) } @@ -481,9 +492,9 @@ class ExcellonParser(object): def wrapper(self, *args, **kwargs): nonlocal name if self.program_state is None: - warnings.warn(f'{name} header statement found before start of header', SyntaxWarning) + self.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', SyntaxWarning) + self.warn(f'{name} header statement found after end of header') fun(self, *args, **kwargs) return wrapper return wrap @@ -495,7 +506,7 @@ class ExcellonParser(object): # of the file. self.generator_hints.append('fritzing') elif self.program_state is not None: - warnings.warn(f'M48 "header start" statement found in the middle of the file, currently in {self.program_state}', SyntaxWarning) + self.warn(f'M48 "header start" statement found in the middle of the file, currently in {self.program_state}') self.program_state = ProgramState.HEADER @exprs.match('M95') @@ -510,7 +521,7 @@ class ExcellonParser(object): self.active_tool = self.tools[self.tools.index(self.active_tool) + 1] else: - warnings.warn('M00 statement found before first tool selection statement.', SyntaxWarning) + self.warn('M00 statement found before first tool selection statement.') @exprs.match('M15') def handle_drill_down(self, match): @@ -524,7 +535,7 @@ class ExcellonParser(object): @exprs.match('M30') 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.warn('M30 statement found before end of header.') self.program_state = ProgramState.FINISHED # ignore. # TODO: maybe add warning if this is followed by other commands. @@ -551,7 +562,7 @@ class ExcellonParser(object): @exprs.match('G00' + xy_coord) def handle_start_routing(self, match): if self.program_state is None: - warnings.warn('Routing mode command found before header.', SyntaxWarning) + self.warn('Routing mode command found before header.') self.program_state = ProgramState.ROUTING self.do_move(match) @@ -572,7 +583,7 @@ class ExcellonParser(object): if self.active_tool: return self.active_tool - warnings.warn('Routing command found before first tool definition.', SyntaxWarning) + self.warn('Routing command found before first tool definition.') return None @exprs.match('(?PG01|G02|G03)' + xy_coord + coord('A') + coord('I') + coord('J')) @@ -598,19 +609,19 @@ class ExcellonParser(object): 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.warn('A/I/J arc coordinates found in linear mode.') self.objects.append(Line(*start, *end, self.active_tool, unit=self.settings.unit)) else: if (x or y) and not (a or i or j): - warnings.warn('Arc without radius found.', SyntaxWarning) + self.warn('Arc without radius found.') clockwise = (self.interpolation_mode == InterpMode.CIRCULAR_CW) if a: # radius given if i or j: - warnings.warn('Arc without both radius and center specified.', SyntaxWarning) + self.warn('Arc without both radius and center specified.') # Convert endpoint-radius-endpoint notation to endpoint-center-endpoint notation. We always use the # smaller arc here. @@ -651,7 +662,7 @@ class ExcellonParser(object): self.settings.number_format = len(integer), len(fractional) elif self.settings.number_format == (None, None) and not metric: - warnings.warn('Using implicit number format from naked "INCH" statement. This is normal for Fritzing, Diptrace, Geda and pcb-rnd.', SyntaxWarning) + self.warn('Using implicit number format from naked "INCH" statement. This is normal for Fritzing, Diptrace, Geda and pcb-rnd.') self.settings.number_format = (2,4) @exprs.match('G90') @@ -680,11 +691,11 @@ class ExcellonParser(object): @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) + self.warn(f'{match[0]} excellon command intended for CAM tools found in EDA file.') @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.warn('Weird G85 excellon slot command used. Please raise an issue on our issue tracker and provide this file for testing.') self.do_move(match, 'X1', 'Y1') start, end = self.do_move(match, 'X2', 'Y2') diff --git a/gerbonara/gerber/graphic_objects.py b/gerbonara/gerber/graphic_objects.py index 98fc094..1f475a6 100644 --- a/gerbonara/gerber/graphic_objects.py +++ b/gerbonara/gerber/graphic_objects.py @@ -249,8 +249,8 @@ class Line(GerberObject): yield from ctx.select_tool(self.tool) yield from ctx.route_mode(self.unit, *self.p1) - x = ctx.settings.write_gerber_value(self.x2, self.unit) - y = ctx.settings.write_gerber_value(self.y2, self.unit) + x = ctx.settings.write_excellon_value(self.x2, self.unit) + y = ctx.settings.write_excellon_value(self.y2, self.unit) yield f'G01X{x}Y{y}' ctx.set_current_point(self.unit, *self.p2) @@ -368,10 +368,10 @@ class Arc(GerberObject): 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.unit) - j = ctx.settings.write_gerber_value(self.cy, self.unit) + x = ctx.settings.write_excellon_value(self.x2, self.unit) + y = ctx.settings.write_excellon_value(self.y2, self.unit) + i = ctx.settings.write_excellon_value(self.cx, self.unit) + j = ctx.settings.write_excellon_value(self.cy, self.unit) yield f'{code}X{x}Y{y}I{i}J{j}' ctx.set_current_point(self.unit, self.x2, self.y2) diff --git a/gerbonara/gerber/rs274x.py b/gerbonara/gerber/rs274x.py index 8089944..859a29e 100644 --- a/gerbonara/gerber/rs274x.py +++ b/gerbonara/gerber/rs274x.py @@ -207,7 +207,10 @@ class GerberFile(CamFile): aperture_map[id(aperture)] = number - gs = GraphicsState(aperture_map=aperture_map, file_settings=settings) + def warn(msg, kls=SyntaxWarning): + warnings.warn(msg, kls) + + gs = GraphicsState(warn=warn, aperture_map=aperture_map, file_settings=settings) for primitive in self.objects: yield from primitive.to_statements(gs) @@ -216,18 +219,18 @@ class GerberFile(CamFile): def __str__(self): return f'' - def save(self, filename, settings=None): + def save(self, filename, settings=None, drop_comments=True): with open(filename, 'w', encoding='utf-8') as f: # Encoding is specified as UTF-8 by spec. - f.write(self.to_gerber(settings)) + f.write(self.to_gerber(settings, drop_comments=drop_comments)) - def to_gerber(self, settings=None): + def to_gerber(self, settings=None, drop_comments=True): # Use given settings, or use same settings as original file if not given, or use defaults if not imported from a # file if settings is None: settings = self.import_settings.copy() or FileSettings() settings.zeros = None settings.number_format = (5,6) - return '\n'.join(self.generate_statements(settings)) + return '\n'.join(self.generate_statements(settings, drop_comments=drop_comments)) @property def is_empty(self): @@ -271,7 +274,7 @@ class GerberFile(CamFile): class GraphicsState: - def __init__(self, file_settings=None, aperture_map=None): + def __init__(self, warn, file_settings=None, aperture_map=None): self.image_polarity = 'positive' # IP image polarity; deprecated self.polarity_dark = True self.point = None @@ -291,6 +294,7 @@ class GraphicsState: self._mat = None self.file_settings = file_settings self.aperture_map = aperture_map or {} + self.warn = warn def __setattr__(self, name, value): # input validation @@ -367,7 +371,7 @@ class GraphicsState: def interpolate(self, x, y, i=None, j=None, aperture=True, multi_quadrant=False, attrs=None): if self.point is None: - warnings.warn('D01 interpolation without preceding D02 move.', SyntaxWarning) + self.warn('D01 interpolation without preceding D02 move.') self.point = (0, 0) old_point = self.map_coord(*self.update_point(x, y)) @@ -376,9 +380,9 @@ class GraphicsState: raise SyntaxError('Interpolation attempted without selecting aperture first') if math.isclose(self.aperture.equivalent_width(), 0): - warnings.warn('D01 interpolation with a zero-size aperture. This is invalid according to spec, ' + self.warn('D01 interpolation with a zero-size aperture. This is invalid according to spec, ' 'however, we pass through the created objects here. Note that these will not show up in e.g. ' - 'SVG output since their line width is zero.', SyntaxWarning) + 'SVG output since their line width is zero.') if self.interpolation_mode == InterpMode.LINEAR: if i is not None or j is not None: @@ -389,15 +393,15 @@ class GraphicsState: else: if i is None and j is None: - warnings.warn('Linear segment implied during arc interpolation mode through D01 w/o I, J values', SyntaxWarning) + self.warn('Linear segment implied during arc interpolation mode through D01 w/o I, J values') return self._create_line(old_point, self.map_coord(*self.point), aperture, attrs) else: if i is None: - warnings.warn('Arc is missing I value', SyntaxWarning) + self.warn('Arc is missing I value') i = 0 if j is None: - warnings.warn('Arc is missing J value', SyntaxWarning) + self.warn('Arc is missing J value') j = 0 return self._create_arc(old_point, self.map_coord(*self.point), (i, j), aperture, multi_quadrant, attrs) @@ -416,6 +420,11 @@ class GraphicsState: polarity_dark=self.polarity_dark, unit=self.file_settings.unit, attrs=attrs) else: + if math.isclose(old_point[0], new_point[0]) and math.isclose(old_point[1], new_point[1]): + # In multi-quadrant mode, an arc with identical start and end points is not rendered at all. Only in + # single-quadrant mode it is rendered as a full circle. + return None + # Super-legacy. No one uses this EXCEPT everything that mentor graphics / siemens make uses this m( (cx, cy) = self.map_coord(*control_point, relative=True) @@ -436,8 +445,8 @@ class GraphicsState: x, y = MM(x, unit), MM(y, unit) if (x is None or y is None) and self.point is None: - warnings.warn('Coordinate omitted from first coordinate statement in the file. This is likely a Siemens ' - 'file. We pretend the omitted coordinate was 0.', SyntaxWarning) + self.warn('Coordinate omitted from first coordinate statement in the file. This is likely a Siemens ' + 'file. We pretend the omitted coordinate was 0.') self.point = (0, 0) if x is None: @@ -509,7 +518,7 @@ class GerberParser: 'image_rotation': fr"^IR(?P{NUMBER})", 'mirror_image': r"^MI(A(?P0|1))?(B(?P0|1))?", 'scale_factor': fr"^SF(A(?P{DECIMAL}))?(B(?P{DECIMAL}))?", - 'aperture_definition': fr"ADD(?P\d+)(?PC|R|O|P|{NAME})(?P,[^,%]*)?$", + 'aperture_definition': fr"ADD(?P\d+)(?PC|R|O|P|{NAME})(,(?P[^,%]*))?$", 'aperture_macro': fr"AM(?P{NAME})\*(?P[^%]*)", 'siemens_garbage': r'^ICAS$', 'old_unit':r'(?PG7[01])', @@ -531,7 +540,7 @@ class GerberParser: self.include_dir = include_dir self.include_stack = [] self.file_settings = FileSettings() - self.graphics_state = GraphicsState(file_settings=self.file_settings) + self.graphics_state = GraphicsState(warn=self.warn, file_settings=self.file_settings) self.aperture_map = {} self.aperture_macros = {} self.current_region = None @@ -544,6 +553,12 @@ class GerberParser: self.file_attrs = {} self.object_attrs = {} self.aperture_attrs = {} + self.filename = None + self.lineno = None + self.line = None + + def warn(self, msg, kls=SyntaxWarning): + warnings.warn('{self.filename}:{self.lineno} "{self.line.replace("\n", "\\n")}": {msg}', kls) @classmethod def _split_commands(kls, data): @@ -580,16 +595,17 @@ class GerberParser: def parse(self, data, filename=None): # filename arg is for error messages - filename = filename or '' + filename = self.filename = filename or '' for lineno, line in self._split_commands(data): if not line.strip(): continue line = line.rstrip('*').strip() + self.lineno, self.line = lineno, line # We cannot assume input gerber to use well-formed statement delimiters. Thus, we may need to parse # multiple statements from one line. if line.strip() and self.eof_found: - warnings.warn('Data found in gerber file after EOF.', SyntaxWarning) + self.warn('Data found in gerber file after EOF.') #print(f'Line {lineno}: {line}') for name, le_regex in self.STATEMENT_REGEXES.items(): @@ -605,7 +621,7 @@ class GerberParser: break else: - warnings.warn(f'Unknown statement found: "{line}", ignoring.', UnknownStatementWarning) + self.warn(f'Unknown statement found: "{line}", ignoring.', UnknownStatementWarning) self.target.comments.append(f'Unknown statement found: "{line}", ignoring.') self.target.apertures = list(self.aperture_map.values()) @@ -614,7 +630,7 @@ class GerberParser: self.target.file_attrs = self.file_attrs if not self.eof_found: - warnings.warn('File is missing mandatory M02 EOF marker. File may be truncated.', SyntaxWarning) + self.warn('File is missing mandatory M02 EOF marker. File may be truncated.') def _parse_coord(self, match): if match['interpolation'] == 'G01': @@ -639,15 +655,15 @@ class GerberParser: if not (op := match['operation']) and has_coord: if self.last_operation == 'D01': - warnings.warn('Coordinate statement without explicit operation code. This is forbidden by spec.', SyntaxWarning) + self.warn('Coordinate statement without explicit operation code. This is forbidden by spec.') op = 'D01' else: if 'siemens' in self.generator_hints: - warnings.warn('Ambiguous coordinate statement. Coordinate statement does not have an operation '\ + self.warn('Ambiguous coordinate statement. Coordinate statement does not have an operation '\ 'mode and the last operation statement was not D01. This is garbage, and forbidden '\ 'by spec. but since this looks like a Siemens/Mentor Graphics file, we will let it '\ - 'slide and treat this as the same as the last operation.', SyntaxWarning) + 'slide and treat this as the same as the last operation.') # Yes, we repeat the last op, and don't do a D01. This is confirmed by # resources/siemens/80101_0125_F200_L12_Bottom.gdo which contains an implicit-double-D02 op = self.last_operation @@ -661,18 +677,21 @@ class GerberParser: if op in ('D1', 'D01'): if self.graphics_state.interpolation_mode != InterpMode.LINEAR: if self.multi_quadrant_mode is None: - warnings.warn('Circular arc interpolation without explicit G75 Single-Quadrant mode statement. '\ - 'This can cause problems with older gerber interpreters.', SyntaxWarning) + self.warn('Circular arc interpolation without explicit G75 Single-Quadrant mode statement. '\ + 'This can cause problems with older gerber interpreters.') elif self.multi_quadrant_mode: - warnings.warn('Deprecated G74 multi-quadant mode arc found. G74 is bad and you should feel bad.', SyntaxWarning) + self.warn('Deprecated G74 multi-quadant mode arc found. G74 is bad and you should feel bad.') if self.current_region is None: - self.target.objects.append(self.graphics_state.interpolate(x, y, i, j, - multi_quadrant=bool(self.multi_quadrant_mode))) + # in multi-quadrant mode this may return None if start and end point of the arc are the same. + obj = self.graphics_state.interpolate(x, y, i, j, multi_quadrant=bool(self.multi_quadrant_mode)) + if obj is not None: + self.target.objects.append(obj) else: - self.current_region.append(self.graphics_state.interpolate(x, y, i, j, aperture=False, - multi_quadrant=bool(self.multi_quadrant_mode))) + obj = self.graphics_state.interpolate(x, y, i, j, aperture=False, multi_quadrant=bool(self.multi_quadrant_mode)) + if obj is not None: + self.current_region.append(obj) elif op in ('D2', 'D02'): self.graphics_state.update_point(x, y) @@ -717,10 +736,10 @@ class GerberParser: if (kls := aperture_classes.get(match['shape'])): if match['shape'] == 'P' and math.isclose(modifiers[0], 0): - warnings.warn('Definition of zero-size polygon aperture. This is invalid according to spec.' , SyntaxWarning) + self.warn('Definition of zero-size polygon aperture. This is invalid according to spec.' ) if match['shape'] in 'RO' and (math.isclose(modifiers[0], 0) or math.isclose(modifiers[1], 0)): - warnings.warn('Definition of zero-width and/or zero-height rectangle or obround aperture. This is invalid according to spec.' , SyntaxWarning) + self.warn('Definition of zero-width and/or zero-height rectangle or obround aperture. This is invalid according to spec.' ) new_aperture = kls(*modifiers, unit=self.file_settings.unit, attrs=self.aperture_attrs.copy()) @@ -773,9 +792,9 @@ class GerberParser: def _parse_include_file(self, match): if self.include_dir is None: - warnings.warn('IF include statement found, but includes are deactivated.', ResourceWarning) + self.warn('IF include statement found, but includes are deactivated.', ResourceWarning) else: - warnings.warn('IF include statement found. Includes are activated, but is this really a good idea?', ResourceWarning) + self.warn('IF include statement found. Includes are activated, but is this really a good idea?', ResourceWarning) include_file = self.include_dir / param["filename"] # Do not check if path exists to avoid leaking existence via error message @@ -796,40 +815,40 @@ class GerberParser: self.include_stack.pop() def _parse_image_name(self, match): - warnings.warn('Deprecated IN (image name) statement found. This deprecated since rev. I4 (Oct 2013).', DeprecationWarning) + self.warn('Deprecated IN (image name) statement found. This deprecated since rev. I4 (Oct 2013).', DeprecationWarning) self.target.comments.append(f'Image name: {match["name"]}') def _parse_load_name(self, match): - warnings.warn('Deprecated LN (load name) statement found. This deprecated since rev. I4 (Oct 2013).', DeprecationWarning) + self.warn('Deprecated LN (load name) statement found. This deprecated since rev. I4 (Oct 2013).', DeprecationWarning) def _parse_axis_selection(self, match): if match['axes'] != 'AXBY': - warnings.warn('Deprecated AS (axis selection) statement found. This deprecated since rev. I1 (Dec 2012).', DeprecationWarning) + self.warn('Deprecated AS (axis selection) statement found. This deprecated since rev. I1 (Dec 2012).', DeprecationWarning) self.graphics_state.output_axes = match['axes'] def _parse_image_polarity(self, match): polarity = dict(POS='positive', NEG='negative')[match['polarity']] if polarity != 'positive': - warnings.warn('Deprecated IP (image polarity) statement found. This deprecated since rev. I4 (Oct 2013).', DeprecationWarning) + self.warn('Deprecated IP (image polarity) statement found. This deprecated since rev. I4 (Oct 2013).', DeprecationWarning) self.graphics_state.image_polarity = polarity def _parse_image_rotation(self, match): rotation = int(match['rotation']) if rotation: - warnings.warn('Deprecated IR (image rotation) statement found. This deprecated since rev. I1 (Dec 2012).', DeprecationWarning) + self.warn('Deprecated IR (image rotation) statement found. This deprecated since rev. I1 (Dec 2012).', DeprecationWarning) self.graphics_state.image_rotation = rotation def _parse_mirror_image(self, match): mirror = bool(int(match['ma'] or '0')), bool(int(match['mb'] or '1')) if mirror != (False, False): - warnings.warn('Deprecated MI (mirror image) statement found. This deprecated since rev. I1 (Dec 2012).', DeprecationWarning) + self.warn('Deprecated MI (mirror image) statement found. This deprecated since rev. I1 (Dec 2012).', DeprecationWarning) self.graphics_state.image_mirror = mirror def _parse_scale_factor(self, match): a = float(match['sa']) if match['sa'] else 1.0 b = float(match['sb']) if match['sb'] else 1.0 if not math.isclose(math.dist((a, b), (1, 1)), 0): - warnings.warn('Deprecated SF (scale factor) statement found. This deprecated since rev. I1 (Dec 2012).', DeprecationWarning) + self.warn('Deprecated SF (scale factor) statement found. This deprecated since rev. I1 (Dec 2012).', DeprecationWarning) self.graphics_state.scale_factor = a, b def _parse_siemens_garbage(self, match): @@ -883,13 +902,13 @@ class GerberParser: def _parse_old_unit(self, match): self.file_settings.unit = Inch if match['mode'] == 'G70' else MM - warnings.warn(f'Deprecated {match["mode"]} unit mode statement found. This deprecated since 2012.', DeprecationWarning) + self.warn(f'Deprecated {match["mode"]} unit mode statement found. This deprecated since 2012.', DeprecationWarning) self.target.comments.append('Replaced deprecated {match["mode"]} unit mode statement with MO statement') def _parse_old_notation(self, match): # FIXME make sure we always have FS at end of processing. self.file_settings.notation = 'absolute' if match['mode'] == 'G90' else 'incremental' - warnings.warn(f'Deprecated {match["mode"]} notation mode statement found. This deprecated since 2012.', DeprecationWarning) + self.warn(f'Deprecated {match["mode"]} notation mode statement found. This deprecated since 2012.', DeprecationWarning) self.target.comments.append('Replaced deprecated {match["mode"]} notation mode statement with FS statement') def _parse_attribute(self, match): diff --git a/gerbonara/gerber/tests/image_support.py b/gerbonara/gerber/tests/image_support.py index a50e243..1dfd34a 100644 --- a/gerbonara/gerber/tests/image_support.py +++ b/gerbonara/gerber/tests/image_support.py @@ -73,6 +73,7 @@ def gerbv_export(in_gbr, out_svg, export_format='svg', origin=(0, 0), size=(6, 6 with tempfile.NamedTemporaryFile('w') as f: if override_unit_spec: units, zeros, digits = override_unit_spec + print(f'{Path(in_gbr).name}: overriding excellon unit spec to {units=} {zeros=} {digits=}') units = 0 if units == 'inch' else 1 zeros = {None: 0, 'leading': 1, 'trailing': 2}[zeros] unit_spec = textwrap.dedent(f'''(cons 'attribs (list diff --git a/gerbonara/gerber/tests/test_excellon.py b/gerbonara/gerber/tests/test_excellon.py index fb25d8f..d89c0a0 100644 --- a/gerbonara/gerber/tests/test_excellon.py +++ b/gerbonara/gerber/tests/test_excellon.py @@ -18,7 +18,7 @@ from ..utils import Inch, MM REFERENCE_FILES = { 'easyeda/Gerber_Drill_NPTH.DRL': (None, None), 'easyeda/Gerber_Drill_PTH.DRL': (None, 'easyeda/Gerber_TopLayer.GTL'), - # Altium uses an excellon format specification format that gerbv doesn't understand, so we have to fix that. + # Altium uses an excellon format specification format that gerbv doesn't understand, so we have to fix that. 'altium-composite-drill/NC Drill/LimeSDR-QPCIe_1v2-SlotHoles.TXT': (('mm', 'leading', 4), None), 'altium-composite-drill/NC Drill/LimeSDR-QPCIe_1v2-RoundHoles.TXT': (('mm', 'leading', 4), 'altium-composite-drill/Gerber/LimeSDR-QPCIe_1v2.GTL'), 'pcb-rnd/power-art.xln': (None, 'pcb-rnd/power-art.gtl'), @@ -40,6 +40,7 @@ REFERENCE_FILES = { def test_round_trip(reference, tmpfile): reference, (unit_spec, _) = reference tmp = tmpfile('Output excellon', '.drl') + print('unit spec', unit_spec) ExcellonFile.open(reference).save(tmp) @@ -48,6 +49,25 @@ def test_round_trip(reference, tmpfile): assert hist[9] == 0 assert hist[3:].sum() < 5e-5*hist.size +@filter_syntax_warnings +@pytest.mark.parametrize('reference', list(REFERENCE_FILES.items()), indirect=True) +def test_idempotence(reference, tmpfile): + reference, (unit_spec, _) = reference + + if reference.name == '80101_0125_F200_ContourPlated.ncd': + # this file contains a duplicate tool definition that we optimize out on our second pass. + # TODO see whether we can change things so we optimize this out on the first pass already. I'm not sure what + # went wrong there. + pytest.skip() + + tmp_1 = tmpfile('First generation output', '.drl') + tmp_2 = tmpfile('Second generation output', '.drl') + + ExcellonFile.open(reference).save(tmp_1) + ExcellonFile.open(tmp_1).save(tmp_2) + + assert tmp_1.read_text() == tmp_2.read_text() + @filter_syntax_warnings @pytest.mark.parametrize('reference', list(REFERENCE_FILES.items()), indirect=True) def test_gerber_alignment(reference, tmpfile, print_on_error): diff --git a/gerbonara/gerber/tests/test_rs274x.py b/gerbonara/gerber/tests/test_rs274x.py index 83148a5..fdd9a81 100644 --- a/gerbonara/gerber/tests/test_rs274x.py +++ b/gerbonara/gerber/tests/test_rs274x.py @@ -275,6 +275,17 @@ def test_round_trip(reference, tmpfile): assert hist[9] == 0 assert hist[3:].sum() < 5e-5*hist.size +@filter_syntax_warnings +@pytest.mark.parametrize('reference', REFERENCE_FILES, indirect=True) +def test_idempotence(reference, tmpfile): + tmp_gbr_1 = tmpfile('First generation output', '.gbr') + tmp_gbr_2 = tmpfile('Second generation output', '.gbr') + + GerberFile.open(reference).save(tmp_gbr_1) + GerberFile.open(tmp_gbr_1).save(tmp_gbr_2) + assert tmp_gbr_1.read_text() == tmp_gbr_2.read_text() + + TEST_ANGLES = [90, 180, 270, 30, 1.5, 10, 360, 1024, -30, -90] TEST_OFFSETS = [(0, 0), (100, 0), (0, 100), (2, 0), (10, 100)] -- cgit