From 516a9d337f717c177ed267fcba3cfed9d4fbe50f Mon Sep 17 00:00:00 2001 From: Flavien Solt Date: Tue, 21 Apr 2026 11:13:15 +0800 Subject: [PATCH] Improve Excellon compatibility parsing --- src/gerbonara/excellon.py | 45 +++++++++++----- tests/test_excellon.py | 107 +++++++++++++++++++++++++++++++++++++- 2 files changed, 139 insertions(+), 13 deletions(-) diff --git a/src/gerbonara/excellon.py b/src/gerbonara/excellon.py index b5a6292..a774559 100755 --- a/src/gerbonara/excellon.py +++ b/src/gerbonara/excellon.py @@ -890,12 +890,17 @@ class ExcellonParser(object): # from https://math.stackexchange.com/a/1781546 if a_s: raise ValueError('Negative arc radius given') - r = settings.parse_gerber_value(a) + r = self.settings.parse_gerber_value(a) x1, y1 = start x2, y2 = end dx, dy = (x2-x1)/2, (y2-y1)/2 x0, y0 = x1+dx, y1+dy - f = math.hypot(dx, dy) / math.sqrt(r**2 - a**2) + d = math.hypot(dx, dy) + if d == 0: + raise ValueError('Arc radius notation requires distinct start and end points') + if r < d: + raise ValueError('Arc radius too small for endpoint distance') + f = math.sqrt(r**2 - d**2) / d if clockwise: cx = x0 + f*dy cy = y0 - f*dx @@ -905,16 +910,16 @@ class ExcellonParser(object): i, j = cx-start[0], cy-start[1] else: # explicit center given - i = settings.parse_gerber_value(i) + i = self.settings.parse_gerber_value(i) or 0 if i_s: i = -i - j = settings.parse_gerber_value(j) + j = self.settings.parse_gerber_value(j) or 0 if j_s: - j = -i + j = -j - self.objects.append(Arc(*start, *end, i, j, True, self.active_tool, unit=self.settings.unit)) + self.objects.append(Arc(*start, *end, i, j, clockwise, self.active_tool, unit=self.settings.unit)) - @exprs.match(r'(M71|METRIC|M72|INCH)(,LZ|,TZ)?(,0*\.0*)?') + @exprs.match(r'(M71|METRIC|M72|INCH)(,LZ|,TZ)?(,([0-9]+\.[0-9]+|0*\.0*))?') def parse_easyeda_format(self, match): metric = match[1] in ('METRIC', 'M71') @@ -927,7 +932,10 @@ class ExcellonParser(object): # This is used by newer autodesk eagles, fritzing and diptrace if match[3]: integer, _, fractional = match[3][1:].partition('.') - self.settings.number_format = len(integer), len(fractional) + if integer.strip('0') or fractional.strip('0'): + self.settings.number_format = int(integer), int(fractional) + else: + self.settings.number_format = len(integer), len(fractional) elif self.settings.number_format == (None, None) and not metric and not self.found_kicad_format_comment: self.warn('Using implicit number format from bare "INCH" statement. This is normal for Fritzing, Diptrace, Geda and pcb-rnd.') @@ -953,10 +961,10 @@ class ExcellonParser(object): @exprs.match('(FMAT|VER),?([0-9]*)') def handle_command_format(self, match): if match[1] == 'FMAT': - # We do not support integer/fractional decimals specification via FMAT because that's stupid. If you need this, - # please raise an issue on our issue tracker, provide a sample file and tell us where on earth you found that - # file. - if match[2] not in ('', '2'): + # We only use FMAT as a compatibility marker. Version 1 drill files encountered in the wild still use the + # same coordinate and routing statements that we already support, so rejecting the header unconditionally + # needlessly breaks otherwise parseable files. + if match[2] not in ('', '1', '2'): raise SyntaxError(f'Unsupported FMAT format version {match[2]}') else: # VER @@ -985,6 +993,19 @@ class ExcellonParser(object): else: self.warn('Bare coordinate after end of file') + @exprs.match(xy_coord + 'G85' + xy_coord) + def handle_g85_slot(self, match): + if self.program_state == ProgramState.HEADER: + return + + self.do_move(match.groups()[:4]) + start, end = self.do_move(match.groups()[4:]) + + if not self.ensure_active_tool(): + return + + self.objects.append(Line(*start, *end, self.active_tool, unit=self.settings.unit)) + @exprs.match(r'DETECT,ON|ATC,ON|M06') def parse_zuken_legacy_statements(self, match): self.generator_hints.append('zuken') diff --git a/tests/test_excellon.py b/tests/test_excellon.py index 964ef8a..ed7e813 100644 --- a/tests/test_excellon.py +++ b/tests/test_excellon.py @@ -24,7 +24,7 @@ from scipy.spatial import KDTree from gerbonara.excellon import ExcellonFile from gerbonara.rs274x import GerberFile from gerbonara.cam import FileSettings -from gerbonara.graphic_objects import Flash +from gerbonara.graphic_objects import Arc, Flash, Line from .image_support import * from .utils import * @@ -195,3 +195,108 @@ def test_syntax_error(): assert 'test_syntax_error.exc' in exc_info.value.msg assert '12' in exc_info.value.msg # lineno + +@filter_syntax_warnings +def test_easyeda_format_supports_explicit_digit_spec(): + data = '\n'.join([ + 'M48', + 'INCH,TZ,2.1', + 'T01C0.0100', + '%', + 'T01', + 'X11Y11', + 'M30', + ]) + + parsed = ExcellonFile.from_string(data) + assert parsed.import_settings.number_format == (2, 1) + + +@filter_syntax_warnings +def test_fmat_1_header_is_accepted(): + data = '\n'.join([ + 'M48', + 'INCH,TZ,2.4', + 'FMAT,1', + 'T01C0.0100', + '%', + 'T01', + 'X010000Y010000', + 'X020000Y010000', + 'M30', + ]) + + parsed = ExcellonFile.from_string(data) + drills = list(parsed.drills()) + assert len(drills) == 2 + + +@filter_syntax_warnings +def test_inline_g85_slot_creates_line_slot(): + data = '\n'.join([ + 'M48', + 'INCH,TZ', + 'T01C0.1000', + '%', + 'T01', + 'X080000Y015000G85X090000Y015000', + 'M30', + ]) + + parsed = ExcellonFile.from_string(data) + slots = list(parsed.slots()) + assert len(slots) == 1 + assert isinstance(slots[0], Line) + assert slots[0].x1 == 8.0 + assert slots[0].y1 == 1.5 + assert slots[0].x2 == 9.0 + assert slots[0].y2 == 1.5 + + +@filter_syntax_warnings +def test_circular_interpolation_uses_signed_center_and_direction(): + data = '\n'.join([ + 'M48', + 'INCH,TZ', + 'T01C0.0100', + '%', + 'T01', + 'G00X000000Y010000', + 'M15', + 'G03X-010000Y000000I000000J-010000', + 'M16', + 'M30', + ]) + + parsed = ExcellonFile.from_string(data) + slots = list(parsed.slots()) + assert len(slots) == 1 + assert isinstance(slots[0], Arc) + assert slots[0].cx == 0.0 + assert slots[0].cy == -1.0 + assert not slots[0].clockwise + + +@filter_syntax_warnings +def test_radius_arc_interpolation_converts_to_center_offset(): + data = '\n'.join([ + 'M48', + 'INCH,TZ', + 'T01C0.0100', + '%', + 'T01', + 'G00X010000Y000000', + 'M15', + 'G03X000000Y010000A010000', + 'M16', + 'M30', + ]) + + parsed = ExcellonFile.from_string(data) + slots = list(parsed.slots()) + assert len(slots) == 1 + assert isinstance(slots[0], Arc) + assert math.isclose(slots[0].cx, -1.0) + assert math.isclose(slots[0].cy, 0.0, abs_tol=1e-12) + assert not slots[0].clockwise +