summaryrefslogtreecommitdiff
path: root/gerbonara
diff options
context:
space:
mode:
Diffstat (limited to 'gerbonara')
-rw-r--r--gerbonara/gerber/cam.py6
-rwxr-xr-xgerbonara/gerber/excellon.py63
-rw-r--r--gerbonara/gerber/ipc356.py64
-rw-r--r--gerbonara/gerber/layer_rules.py16
-rw-r--r--gerbonara/gerber/layers.py40
-rw-r--r--gerbonara/gerber/rs274x.py3
-rw-r--r--gerbonara/gerber/tests/image_support.py12
-rw-r--r--gerbonara/gerber/tests/test_excellon.py33
-rw-r--r--gerbonara/gerber/tests/test_layers.py4
-rw-r--r--gerbonara/gerber/tests/test_rs274x.py17
-rw-r--r--gerbonara/gerber/utils.py3
11 files changed, 195 insertions, 66 deletions
diff --git a/gerbonara/gerber/cam.py b/gerbonara/gerber/cam.py
index 6c6a7dc..4c8ab19 100644
--- a/gerbonara/gerber/cam.py
+++ b/gerbonara/gerber/cam.py
@@ -147,11 +147,13 @@ class FileSettings:
if self.zeros == 'leading':
value = '0'*decimal_digits + value # pad with zeros to ensure we have enough decimals
- return float(sign + value[:-decimal_digits] + '.' + value[-decimal_digits:])
+ out = float(sign + value[:-decimal_digits] + '.' + value[-decimal_digits:])
else: # no or trailing zero suppression
value = value + '0'*integer_digits
- return float(sign + value[:integer_digits] + '.' + value[integer_digits:])
+ out = float(sign + value[:integer_digits] + '.' + value[integer_digits:])
+ print(self.zeros, self.number_format, value, out)
+ return out
def write_gerber_value(self, value, unit=None):
""" Convert a floating point number to a Gerber/Excellon-formatted string. """
diff --git a/gerbonara/gerber/excellon.py b/gerbonara/gerber/excellon.py
index b3fbd97..3382ffe 100755
--- a/gerbonara/gerber/excellon.py
+++ b/gerbonara/gerber/excellon.py
@@ -38,31 +38,43 @@ class ExcellonContext:
self.mode = None
self.current_tool = None
self.x, self.y = None, None
+ self.drill_down = False
def select_tool(self, tool):
if self.current_tool != tool:
+ if self.drill_down:
+ yield 'M16' # drill up
+ self.drill_down = False
+
self.current_tool = tool
yield f'T{self.tools[id(tool)]:02d}'
def drill_mode(self):
if self.mode != ProgramState.DRILLING:
self.mode = ProgramState.DRILLING
- yield 'G05'
+ if self.drill_down:
+ yield 'M16' # drill up
+ self.drill_down = False
+ yield 'G05' # drill mode
def route_mode(self, unit, x, y):
x, y = self.settings.unit(x, unit), self.settings.unit(y, unit)
- if self.mode == ProgramState.ROUTING:
- if (self.x, self.y) == (x, y):
- return # nothing to do
- else:
- yield 'M16' # drill up
+ if self.mode == ProgramState.ROUTING and (self.x, self.y) == (x, y):
+ return # nothing to do
+
+ if self.drill_down:
+ yield 'M16' # drill up
+ # route mode
yield 'G00' + 'X' + self.settings.write_excellon_value(x) + 'Y' + self.settings.write_excellon_value(y)
yield 'M15' # drill down
+ self.drill_down = True
+ self.mode = ProgramState.ROUTING
+ self.x, self.y = x, y
def set_current_point(self, unit, x, y):
- self.current_point = self.settings.unit(x, unit), self.settings.unit(y, unit)
+ self.x, self.y = 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
@@ -71,7 +83,7 @@ def parse_allegro_ncparam(data, settings=None):
# still be able to extract the same information from the human-readable ncdrill.log.
if settings is None:
- settings = FileSettings(number_format=(None, None))
+ settings = FileSettings(number_format=(None, None), zeros='leading')
lz_supp, tz_supp = False, False
nf_int, nf_frac = settings.number_format
@@ -118,7 +130,7 @@ def parse_allegro_logfile(data):
for line in data.splitlines():
line = line.strip()
- line = re.sub('\s+', ' ', line)
+ line = re.sub(r'\s+', ' ', line)
if (m := re.match(r'OUTPUT-UNITS (METRIC|ENGLISH|INCHES)', line)):
# I have no idea wth is the difference between "ENGLISH" and "INCHES". I think one might just be the one
@@ -400,7 +412,7 @@ class ExcellonParser(object):
# SyntaxError. In case of e.g. Allegro files where the number format and other options are specified separately
# from the excellon file, the caller must pass in an already filled-out FileSettings object.
if settings is None:
- self.settings = FileSettings(number_format=(None, None))
+ self.settings = FileSettings(number_format=(None, None), zeros='leading')
else:
self.settings = settings
self.program_state = None
@@ -448,7 +460,7 @@ class ExcellonParser(object):
# TODO check first command in file is "start of header" command.
try:
- #print(f'{lineno} "{line}"', end=' ')
+ print(f'{self.settings.number_format} {lineno} "{line}"')
if not self.exprs.handle(self, line):
raise ValueError('Unknown excellon statement:', line)
except Exception as e:
@@ -540,6 +552,7 @@ class ExcellonParser(object):
coord = lambda name, key=None: fr'({name}(?P<{key or name}>[+-]?[0-9]*\.?[0-9]*))?'
xy_coord = coord('X') + coord('Y')
+ xyaij_coord = xy_coord + coord('A') + coord('I') + coord('J')
@exprs.match(r'R(?P<count>[0-9]+)' + xy_coord)
def handle_repeat_hole(self, match):
@@ -657,7 +670,7 @@ class ExcellonParser(object):
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'))
+ @exprs.match('(?P<mode>G01|G02|G03)' + xyaij_coord)
def handle_linear_mode(self, match):
if match['mode'] == 'G01':
self.interpolation_mode = InterpMode.LINEAR
@@ -733,7 +746,7 @@ class ExcellonParser(object):
self.settings.number_format = len(integer), len(fractional)
elif self.settings.number_format == (None, None) and not metric:
- self.warn('Using implicit number format from naked "INCH" statement. This is normal for Fritzing, Diptrace, Geda and pcb-rnd.')
+ self.warn('Using implicit number format from bare "INCH" statement. This is normal for Fritzing, Diptrace, Geda and pcb-rnd.')
self.settings.number_format = (2,4)
@exprs.match('G90')
@@ -776,18 +789,24 @@ class ExcellonParser(object):
# slots.
self.objects.append(Line(*start, *end, self.active_tool, unit=self.settings.unit))
- @exprs.match(xy_coord)
- def handle_naked_coordinate(self, match):
- _start, end = self.do_move(match)
+ @exprs.match(xyaij_coord)
+ def handle_bare_coordinate(self, match):
+ # Yes, drills in the header doesn't follow the specification, but it there are many files like this.
+ if self.program_state in (ProgramState.DRILLING, ProgramState.HEADER):
+ _start, end = self.do_move(match)
- if not self.ensure_active_tool():
- return
+ 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))
+
+ elif self.program_state == ProgramState.ROUTING:
+ # Bare coordinates for routing also seem illegal, but Siemens actually uses these.
+ # Example file: siemens/80101_0125_F200_ContourPlated.ncd
+ self.do_interpolation(match)
- self.objects.append(Flash(*end, self.active_tool, unit=self.settings.unit))
+ else:
+ self.warn('Bare coordinate after end of file')
@exprs.match(r'; Format\s*: ([0-9]+\.[0-9]+) / (Absolute|Incremental) / (Inch|MM) / (Leading|Trailing)')
def parse_siemens_format(self, match):
diff --git a/gerbonara/gerber/ipc356.py b/gerbonara/gerber/ipc356.py
index 043f2ab..dc3773b 100644
--- a/gerbonara/gerber/ipc356.py
+++ b/gerbonara/gerber/ipc356.py
@@ -25,12 +25,12 @@ from dataclasses import dataclass, KW_ONLY
from pathlib import Path
from .cam import CamFile, FileSettings
-from .utils import MM, Inch, LengthUnit
+from .utils import MM, Inch, LengthUnit, rotate_point
class Netlist(CamFile):
def __init__(self, test_records=None, conductors=None, outlines=None, comments=None, adjacency=None,
- params=None, import_settings=None, original_path=None):
+ params=None, import_settings=None, original_path=None, generator_hints=None):
super().__init__(original_path=original_path, layer_name='netlist', import_settings=import_settings)
self.test_records = test_records or []
self.conductors = conductors or []
@@ -38,10 +38,14 @@ class Netlist(CamFile):
self.comments = comments or []
self.adjacency = adjacency or {}
self.params = params or {}
+ self.generator_hints = generator_hints or []
def merge(self, other, our_prefix=None, their_prefix=None):
''' Merge other netlist into this netlist. The respective net names are prefixed with the given prefixes
(default: None). Garbles other. '''
+ if other is None:
+ return
+
if not isinstance(other, Netlist):
raise TypeError(f'Can only merge Netlist with other Netlist, not {type(other)}')
@@ -80,12 +84,14 @@ class Netlist(CamFile):
self.adjacency = new_adjacency
def offset(self, dx=0, dy=0, unit=MM):
- # FIXME
- pass
+ for obj in self.objects:
+ obj.offset(dx, dy, unit)
def rotate(self, angle:'radian', center=(0,0), unit=MM):
- # FIXME
- pass
+ cx, cy = center
+
+ for obj in self.objects:
+ obj.rotate(angle, cx, cy, unit)
@property
def objects(self):
@@ -232,7 +238,7 @@ class NetlistParser(object):
self.adjacency = {}
self.outlines = []
self.eof = False
- self.generator = None
+ self.generator_hints = []
def warn(self, msg, kls=SyntaxWarning):
warnings.warn(f'{self.filename}:{self.start_line}: {msg}', kls)
@@ -264,7 +270,8 @@ class NetlistParser(object):
raise SyntaxError(f'Error parsing {self.filename}:{lineno}: {e}') from e
return Netlist(self.test_records, self.conductors, self.outlines, self.comments, self.adjacency,
- params=self.params, import_settings=self.settings, original_path=path)
+ params=self.params, import_settings=self.settings, original_path=path,
+ generator_hints=self.generator_hints)
def _parse_line(self, line):
if not line:
@@ -278,7 +285,7 @@ class NetlistParser(object):
# +-- sic!
# v
if 'Ouptut' in line and 'Allegro' in line:
- self.generator = 'allegro'
+ self.generator_hints.append('allegro')
elif 'Ouptut' not in line and 'Allegro' in line:
self.warn('This seems to be a file generated by a newer allegro version. Please raise an issue on our '
@@ -286,13 +293,13 @@ class NetlistParser(object):
'so we can improve Gerbonara!')
elif 'EAGLE' in line and 'CadSoft' in line:
- self.generator = 'eagle'
+ self.generator_hints.append('eagle')
if line.strip().startswith('NNAME'):
name, *value = line.strip().split()
value = ' '.join(value)
self.warn('File contains non-standard Allegro-style net name alias definitions in comments.')
- if self.generator == 'allegro':
+ if 'allegro' in self.generator_hints:
# it's amazing how allegro always seems to have found a way to do the same thing everyone else is
# doing just in a different, slightly more messed up, completely incompatible way.
self.net_names[name] = value[5:] # strip NNAME because Allegro
@@ -328,7 +335,7 @@ class NetlistParser(object):
raise SyntaxError(f'Unsupported IPC-356 netlist unit specification "{line}"')
elif name.startswith('NNAME'):
- if self.generator == 'allegro':
+ if 'allegro' in self.generator_hints:
self.net_names[name] = value[5:]
else:
@@ -406,6 +413,19 @@ class TestRecord:
y = self.unit.format(self.y)
return f'<IPC-356 test record @ {x},{y} {self.net_name} {self.pad_type.name} at {self.ref_des}, pin {self.pin_num}>'
+ def rotate(self, angle, cx=0, cy=0, unit=None):
+ cx = self.unit(cx, unit)
+ cy = self.unit(cy, unit)
+
+ self.angle += angle
+ self.x, self.y = rotate_point(self.x, self.y, angle, center=(cx, cy))
+
+ def offset(self, dx=0, dy=0, unit=None):
+ dx = self.unit(dx, unit)
+ dy = self.unit(dy, unit)
+ self.x += dx
+ self.y += dy
+
@classmethod
def parse(kls, line, settings, net_name_map={}):
obj = kls()
@@ -552,6 +572,16 @@ class Outline:
def __str__(self):
return f'<IPC-356 {self.outline_type.name} outline with {len(self.outline)} points>'
+ def rotate(self, angle, cx=0, cy=0, unit=None):
+ cx = self.unit(cx, unit)
+ cy = self.unit(cy, unit)
+ self.outline = [ rotate_point(x, y, angle, center=(cx, cy)) for x, y in self.outline ]
+
+ def offset(self, dx=0, dy=0, unit=None):
+ dx = self.unit(dx, unit)
+ dy = self.unit(dy, unit)
+ self.outline = [ (x+dx, y+dy) for x, y in self.outline ]
+
@dataclass
class Conductor:
@@ -588,3 +618,13 @@ class Conductor:
def __str__(self):
return f'<IPC-356 conductor {self.net_name} with {len(self.coords)} points>'
+ def rotate(self, angle, cx=0, cy=0, unit=None):
+ cx = self.unit(cx, unit)
+ cy = self.unit(cy, unit)
+ self.coords = [ rotate_point(x, y, angle, center=(cx, cy)) for x, y in self.coords ]
+
+ def offset(self, dx=0, dy=0, unit=None):
+ dx = self.unit(dx, unit)
+ dy = self.unit(dy, unit)
+ self.coords = [ (x+dx, y+dy) for x, y in self.coords ]
+
diff --git a/gerbonara/gerber/layer_rules.py b/gerbonara/gerber/layer_rules.py
index 3d05cc0..a042050 100644
--- a/gerbonara/gerber/layer_rules.py
+++ b/gerbonara/gerber/layer_rules.py
@@ -15,6 +15,7 @@ MATCH_RULES = {
# this rule is slightly generic to catch the drill files of things like geda and pcb-rnd that otherwise use altium's
# layer names.
'drill unknown': r'.*\.(txt|drl|xln)',
+ 'other netlist': r'.*\.ipc',
},
'kicad': {
@@ -29,6 +30,7 @@ MATCH_RULES = {
'inner copper': r'.*\.gp?([0-9]+)|.*inn?e?r?([0-9]+).cu.*',
'mechanical outline': r'.*\.(gm[0-9]+)|.*edge.cuts.*',
'drill plated': r'.*\.(drl)',
+ 'other netlist': r'.*\.d356',
},
'geda': {
@@ -44,6 +46,7 @@ MATCH_RULES = {
'mechanical outline': r'.*\.outline\.gbr',
'drill plated': r'.*\.plated-drill.cnc',
'drill nonplated': r'.*\.unplated-drill.cnc',
+ 'other netlist': r'.*\.ipc', # default rule due to lack of tool-specific examples
},
'diptrace': {
@@ -58,6 +61,7 @@ MATCH_RULES = {
'inner copper': r'.*_inner_l([0-9]+).*',
'bottom paste': r'.*_boardoutline\.\w+', # FIXME verify this
'drill plated': r'.*\.(drl)', # diptrace has unplated drills on the outline layer
+ 'other netlist': r'.*\.ipc', # default rule due to lack of tool-specific examples
},
'target': {
@@ -71,6 +75,7 @@ MATCH_RULES = {
'bottom paste': r'.*\.PasteBot',
'mechanical outline': r'.*\.Outline',
'drill plated': r'.*\.Drill',
+ 'other netlist': r'.*\.ipc', # default rule due to lack of tool-specific examples
},
'orcad': {
@@ -86,6 +91,7 @@ MATCH_RULES = {
'mechanical outline': r'.*\.(fab|drd)',
'drill plated': r'.*\.tap',
'drill nonplated': r'.*\.npt',
+ 'other netlist': r'.*\.ipc', # default rule due to lack of tool-specific examples
},
'eagle': {
@@ -101,6 +107,7 @@ MATCH_RULES = {
'inner copper': r'.*\.ly([0-9]+)|.*\.internalplane([0-9]+)\.ger',
'mechanical outline': r'.*(\.dim|\.mil|\.gml)|.*\.(?:board)?outline\.ger|profile\.gbr',
'drill plated': r'.*\.(txt|exc|drd|xln)',
+ 'other netlist': r'.*\.ipc',
},
'siemens': {
@@ -119,6 +126,7 @@ MATCH_RULES = {
# match these last to avoid shadowing other layers via substring match
'top copper': r'.*[^enk]Top.gdo',
'bottom copper': r'.*[^enk]Bottom.gdo',
+ 'other netlist': r'.*\.ipc', # default rule due to lack of tool-specific examples
},
'allegro': {
@@ -130,5 +138,13 @@ MATCH_RULES = {
# put .log file last to prefer .txt
'excellon params': r'ncdrill\.log',
'excellon params': r'ncroute\.log',
+ 'other netlist': r'.*\.ipc', # default rule due to lack of tool-specific examples
},
+
+'pads': {
+ # Pads also does not seem to have a factory-default naming schema. Or it has one but everyone ignores it.
+ 'generic gerber': r'.*\.pho',
+ 'drill mech': r'.*\.drl',
+ },
+
}
diff --git a/gerbonara/gerber/layers.py b/gerbonara/gerber/layers.py
index 996bfec..fcaf0fc 100644
--- a/gerbonara/gerber/layers.py
+++ b/gerbonara/gerber/layers.py
@@ -66,10 +66,16 @@ def best_match(filenames):
def identify_file(data):
if 'M48' in data:
return 'excellon'
+
if 'G90' in data and ';LEADER:' in data: # yet another allegro special case
return 'excellon'
+
if 'FSLAX' in data or 'FSTAX' in data:
return 'gerber'
+
+ if 'UNITS CUST' in data:
+ return 'ipc356'
+
return None
def common_prefix(l):
@@ -111,7 +117,7 @@ def autoguess(filenames):
def layername_autoguesser(fn):
fn, _, ext = fn.lower().rpartition('.')
- if ext == 'log':
+ if ext in ('log', 'err'):
return 'unknown unknown'
side, use = 'unknown', 'unknown'
@@ -149,8 +155,16 @@ def layername_autoguesser(fn):
use = 'copper'
elif re.search('out(line)?', fn):
- use = 'mechanical'
- side = 'outline'
+ use = 'outline'
+ side = 'mechanical'
+
+ elif 'ipc' in fn and '356' in fn:
+ use = 'netlist'
+ side = 'other'
+
+ elif 'netlist' in fn:
+ use = 'netlist'
+ side = 'other'
return f'{side} {use}'
@@ -224,6 +238,7 @@ class LayerStack:
raise SystemError(f'Ambiguous layer names for {", ".join(ambiguous)}')
drill_layers = []
+ netlist = None
layers = {} # { tuple(key.split()): None for key in STANDARD_LAYERS }
for key, paths in filemap.items():
if len(paths) > 1 and not 'drill' in key:
@@ -232,7 +247,11 @@ class LayerStack:
for path in paths:
id_result = identify_file(path.read_text())
print('id_result', id_result)
- if ('outline' in key or 'drill' in key) and id_result != 'gerber':
+
+ if 'netlist' in key:
+ layer = Netlist.open(path)
+
+ elif ('outline' in key or 'drill' in key) and id_result != 'gerber':
if id_result is None:
# Since e.g. altium uses ".txt" as the extension for its drill files, we have to assume the
# current file might not be a drill file after all.
@@ -246,6 +265,7 @@ class LayerStack:
plated = None
layer = ExcellonFile.open(path, plated=plated, settings=excellon_settings)
else:
+
layer = GerberFile.open(path)
if key == 'mechanical outline':
@@ -254,6 +274,12 @@ class LayerStack:
elif 'drill' in key:
drill_layers.append(layer)
+ elif 'netlist' in key:
+ if netlist:
+ warnings.warn(f'Found multiple netlist files, using only first one. Have: {netlist.original_path.name}, got {path.name}')
+ else:
+ netlist = layer
+
else:
side, _, use = key.partition(' ')
layers[(side, use)] = layer
@@ -266,12 +292,13 @@ class LayerStack:
board_name = common_prefix([l.original_path.name for l in layers.values() if l is not None])
board_name = re.sub(r'^\W+', '', board_name)
board_name = re.sub(r'\W+$', '', board_name)
- return kls(layers, drill_layers, board_name=board_name)
+ return kls(layers, drill_layers, netlist, board_name=board_name)
- def __init__(self, graphic_layers, drill_layers, board_name=None):
+ def __init__(self, graphic_layers, drill_layers, netlist=None, board_name=None):
self.graphic_layers = graphic_layers
self.drill_layers = drill_layers
self.board_name = board_name
+ self.netlist = netlist
def __str__(self):
names = [ f'{side} {use}' for side, use in self.graphic_layers ]
@@ -451,4 +478,5 @@ class LayerStack:
self.drill_pth.merge(other.drill_pth)
self.drill_npth.merge(other.drill_npth)
self.drill_unknown.merge(other.drill_unknown)
+ self.netlist.merge(other.netlist)
diff --git a/gerbonara/gerber/rs274x.py b/gerbonara/gerber/rs274x.py
index 07dbac4..d3719b3 100644
--- a/gerbonara/gerber/rs274x.py
+++ b/gerbonara/gerber/rs274x.py
@@ -873,6 +873,9 @@ class GerberParser:
if cmt.startswith('File Origin:') and 'Allegro' in cmt:
self.generator_hints.append('allegro')
+ elif cmt.startswith('PADS') and 'generated Gerber' in cmt:
+ self.generator_hints.append('pads')
+
elif cmt.startswith('Layer:'):
if 'BOARD GEOMETRY' in cmt:
if 'SOLDERMASK_TOP' in cmt:
diff --git a/gerbonara/gerber/tests/image_support.py b/gerbonara/gerber/tests/image_support.py
index 1dfd34a..913a4bf 100644
--- a/gerbonara/gerber/tests/image_support.py
+++ b/gerbonara/gerber/tests/image_support.py
@@ -61,7 +61,8 @@ def run_cargo_cmd(cmd, args, **kwargs):
except FileNotFoundError:
return subprocess.run([str(Path.home() / '.cargo' / 'bin' / cmd), *args], **kwargs)
-def svg_to_png(in_svg, out_png, dpi=100, bg='black'):
+def svg_to_png(in_svg, out_png, dpi=100, bg=None):
+ bg = 'black' if bg is None else bg
run_cargo_cmd('resvg', ['--background', bg, '--dpi', str(dpi), in_svg, out_png], check=True, stdout=subprocess.DEVNULL)
to_gerbv_svg_units = lambda val, unit='mm': val*72 if unit == 'inch' else val/25.4*72
@@ -89,6 +90,9 @@ def gerbv_export(in_gbr, out_svg, export_format='svg', origin=(0, 0), size=(6, 6
color = f"(cons 'color #({r*257} {g*257} {b*257}))"
f.write(f'''(gerbv-file-version! "2.0A")(define-layer! 0 (cons 'filename "{in_gbr}"){unit_spec}{color})''')
f.flush()
+ if override_unit_spec:
+ import shutil
+ shutil.copy(f.name, '/tmp/foo.gbv')
x, y = origin
w, h = size
@@ -194,12 +198,12 @@ def gerber_difference_merge(ref1, ref2, actual, diff_out=None, composite_out=Non
return svg_difference(ref1_svg.name, act_svg.name, diff_out=diff_out)
-def svg_difference(reference, actual, diff_out=None):
+def svg_difference(reference, actual, diff_out=None, background=None):
with tempfile.NamedTemporaryFile(suffix='-ref.png') as ref_png,\
tempfile.NamedTemporaryFile(suffix='-act.png') as act_png:
- svg_to_png(reference, ref_png.name)
- svg_to_png(actual, act_png.name)
+ svg_to_png(reference, ref_png.name, bg=background)
+ svg_to_png(actual, act_png.name, bg=background)
return image_difference(ref_png.name, act_png.name, diff_out=diff_out)
diff --git a/gerbonara/gerber/tests/test_excellon.py b/gerbonara/gerber/tests/test_excellon.py
index d89c0a0..6267f65 100644
--- a/gerbonara/gerber/tests/test_excellon.py
+++ b/gerbonara/gerber/tests/test_excellon.py
@@ -19,8 +19,8 @@ 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-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'),
+ 'altium-composite-drill/NC Drill/LimeSDR-QPCIe_1v2-SlotHoles.TXT': (('mm', 'trailing', 4), None),
+ 'altium-composite-drill/NC Drill/LimeSDR-QPCIe_1v2-RoundHoles.TXT': (('mm', 'trailing', 4), 'altium-composite-drill/Gerber/LimeSDR-QPCIe_1v2.GTL'),
'pcb-rnd/power-art.xln': (None, 'pcb-rnd/power-art.gtl'),
'siemens/80101_0125_F200_ThruHoleNonPlated.ncd': (None, None),
'siemens/80101_0125_F200_ThruHolePlated.ncd': (None, 'siemens/80101_0125_F200_L01_Top.gdo'),
@@ -42,7 +42,13 @@ def test_round_trip(reference, tmpfile):
tmp = tmpfile('Output excellon', '.drl')
print('unit spec', unit_spec)
- ExcellonFile.open(reference).save(tmp)
+ f = ExcellonFile.open(reference)
+ f.save(tmp)
+
+ if reference.name == '80101_0125_F200_ContourPlated.ncd':
+ # gerbv does not support routed slots in excellon files at all and renders garbage for the reference file here
+ # due to its use of bare coordinates for routed slots. Thus, we skip this test (for now).
+ return
mean, _max, hist = gerber_difference(reference, tmp, diff_out=tmpfile('Difference', '.png'), ref_unit_spec=unit_spec)
assert mean < 5e-5
@@ -51,6 +57,27 @@ def test_round_trip(reference, tmpfile):
@filter_syntax_warnings
@pytest.mark.parametrize('reference', list(REFERENCE_FILES.items()), indirect=True)
+def test_first_level_idempotence_svg(reference, tmpfile):
+ reference, (unit_spec, _) = reference
+ tmp = tmpfile('Output excellon', '.drl')
+ ref_svg = tmpfile('Reference SVG render', '.svg')
+ out_svg = tmpfile('Output SVG render', '.svg')
+ print('unit spec', unit_spec)
+
+ a = ExcellonFile.open(reference)
+ a.save(tmp)
+ b = ExcellonFile.open(tmp)
+
+ ref_svg.write_text(str(a.to_svg(fg='black', bg='white')))
+ out_svg.write_text(str(b.to_svg(fg='black', bg='white')))
+
+ mean, _max, hist = svg_difference(ref_svg, out_svg, diff_out=tmpfile('Difference', '.png'), background='white')
+ assert mean < 5e-5
+ 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
diff --git a/gerbonara/gerber/tests/test_layers.py b/gerbonara/gerber/tests/test_layers.py
index 792316f..3f72cd8 100644
--- a/gerbonara/gerber/tests/test_layers.py
+++ b/gerbonara/gerber/tests/test_layers.py
@@ -44,7 +44,7 @@ REFERENCE_DIRS = {
},
'allegro': {
- '08_057494d-ipc356.ipc': None,
+ '08_057494d-ipc356.ipc': 'other netlist',
'08_057494d.rou': 'mechanical outline',
'Read_Me.1': None,
'art_param.txt': None,
@@ -71,7 +71,7 @@ REFERENCE_DIRS = {
'allegro-2': {
'MINNOWMAX_REVA2_PUBLIC_BOTTOMSIDE.pdf': None,
'MINNOWMAX_REVA2_PUBLIC_TOPSIDE.pdf': None,
- 'MinnowMax_RevA1_IPC356A.ipc': None,
+ 'MinnowMax_RevA1_IPC356A.ipc': 'other netlist',
'MinnowMax_RevA1_DRILL/MinnowMax_RevA1_NCDRILL.drl': 'drill unknown',
'MinnowMax_RevA1_DRILL/MinnowMax_RevA1_NCROUTE.rou': 'drill unknown',
'MinnowMax_RevA1_DRILL/nc_param.txt': None,
diff --git a/gerbonara/gerber/tests/test_rs274x.py b/gerbonara/gerber/tests/test_rs274x.py
index fdd9a81..13192a3 100644
--- a/gerbonara/gerber/tests/test_rs274x.py
+++ b/gerbonara/gerber/tests/test_rs274x.py
@@ -116,19 +116,6 @@ REFERENCE_FILES = [ l.strip() for l in '''
altium-composite-drill/Gerber/LimeSDR-QPCIe_1v2.GBO
altium-composite-drill/Gerber/LimeSDR-QPCIe_1v2.G8
altium-composite-drill/Gerber/LimeSDR-QPCIe_1v2.GPT
- allegro/mask_prm.art
- allegro/paste_sec.art
- allegro/assy2.art
- allegro/l3_vcc.art
- allegro/l1_primary.art
- allegro/silk_prm.art
- allegro/l2_gnd.art
- allegro/assy1.art
- allegro/fab1.art
- allegro/l4_secondary.art
- allegro/mask_sec.art
- allegro/paste_prm.art
- allegro/silk_sec.art
geda/driver.topmask.gbr
geda/controller.top.gbr
geda/controller.bottom.gbr
@@ -456,10 +443,10 @@ def test_svg_export(reference, tmpfile):
ref_svg = tmpfile('Reference export', '.svg')
ref_png = tmpfile('Reference render', '.png')
gerbv_export(reference, ref_svg, origin=bounds[0], size=bounds[1], fg='#000000', bg='#ffffff')
- svg_to_png(ref_svg, ref_png, dpi=72) # make dpi match Cairo's default
+ svg_to_png(ref_svg, ref_png, dpi=72, bg='white') # make dpi match Cairo's default
out_png = tmpfile('Output render', '.png')
- svg_to_png(out_svg, out_png, dpi=72) # make dpi match Cairo's default
+ svg_to_png(out_svg, out_png, dpi=72, bg='white') # make dpi match Cairo's default
mean, _max, hist = image_difference(ref_png, out_png, diff_out=tmpfile('Difference', '.png'))
assert mean < 1.2e-3
diff --git a/gerbonara/gerber/utils.py b/gerbonara/gerber/utils.py
index 6ea8bcb..71251de 100644
--- a/gerbonara/gerber/utils.py
+++ b/gerbonara/gerber/utils.py
@@ -97,6 +97,9 @@ class LengthUnit:
def __str__(self):
return self.shorthand
+ def __repr__(self):
+ return f'<LengthUnit {self.name}>'
+
MILLIMETERS_PER_INCH = 25.4
Inch = LengthUnit('inch', 'in', MILLIMETERS_PER_INCH)