kopia lustrzana https://gitlab.com/gerbolyze/gerbonara
Add more tests
rodzic
4a6d76c557
commit
a1c1d6d971
|
@ -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.
|
||||
|
|
|
@ -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 '<unknown>'
|
||||
self.filename = filename = filename or '<unknown>'
|
||||
|
||||
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('(?P<mode>G01|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')
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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'<GerberFile with {len(self.apertures)} apertures, {len(self.objects)} objects>'
|
||||
|
||||
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<rotation>{NUMBER})",
|
||||
'mirror_image': r"^MI(A(?P<ma>0|1))?(B(?P<mb>0|1))?",
|
||||
'scale_factor': fr"^SF(A(?P<sa>{DECIMAL}))?(B(?P<sb>{DECIMAL}))?",
|
||||
'aperture_definition': fr"ADD(?P<number>\d+)(?P<shape>C|R|O|P|{NAME})(?P<modifiers>,[^,%]*)?$",
|
||||
'aperture_definition': fr"ADD(?P<number>\d+)(?P<shape>C|R|O|P|{NAME})(,(?P<modifiers>[^,%]*))?$",
|
||||
'aperture_macro': fr"AM(?P<name>{NAME})\*(?P<macro>[^%]*)",
|
||||
'siemens_garbage': r'^ICAS$',
|
||||
'old_unit':r'(?P<mode>G7[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 '<unknown>'
|
||||
filename = self.filename = filename or '<unknown>'
|
||||
|
||||
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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)]
|
||||
|
||||
|
|
Ładowanie…
Reference in New Issue