Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/test-check-all.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ jobs:
steps:
- uses: actions/checkout@v5

- name: Check KiCad Gerber naming and cleanup
run: python3 -m unittest discover -s tests -p 'test_kicad_gerbers.py'

- name: Install kicad 10
run: |
sudo add-apt-repository --yes ppa:kicad/kicad-10.0-releases
Expand Down
33 changes: 33 additions & 0 deletions bin/cleanup_kicad_gerbers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Remove obsolete KiCad 8 inner-layer exports when KiCad 10 replacements exist."""

import argparse
from pathlib import Path


def cleanup(directory, board, dry_run=False):
for layer in (1, 2):
current = directory / (board + "-In%d_Cu.g%d" % (layer, layer))
obsolete = directory / (board + "-In%d_Cu.g%d" % (layer, layer + 1))
if current.is_file() and obsolete.is_file():
print("%s %s (replaced by %s)" % (
"Would remove" if dry_run else "Removing", obsolete, current.name))
if not dry_run:
obsolete.unlink()


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("directory", type=Path, help="Gerber export directory")
parser.add_argument("board", help="PCB basename, e.g. uaefi-pro")
parser.add_argument("--dry-run", action="store_true", help="Only list obsolete files")
args = parser.parse_args()
if not args.directory.is_dir():
parser.error("Gerber directory does not exist: %s" % args.directory)
if not args.board or Path(args.board).name != args.board or args.board in (".", ".."):
parser.error("board must be a basename without a directory")
cleanup(args.directory, args.board, args.dry_run)


if __name__ == "__main__":
main()
22 changes: 12 additions & 10 deletions bin/copy_from_Kicad.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,24 +65,28 @@ def mkdir_p(path):
print ("Reading gerbers from " + src_name + "*.*")
gerbers = [ ".GTL", ".GTO", ".GTP", ".GTS", ".GBL", ".GBO", ".GBS", ".GBP", ".GM1", ".DRL" ]
if (num_layers >= 4):
gerbers += [ ".G2", ".G3" ]
# Match logical layer names: KiCad 8 used g2/g3, KiCad 10 uses g1/g2.
# An extension-only glob can copy In2 over In1 when both exports coexist.
current_inner = [src_name + "-In%d_Cu.g%d" % (i, i) for i in (1, 2)]
legacy_inner = [src_name + "-In%d_Cu.g%d" % (i, i + 1) for i in (1, 2)]
inner = current_inner if any(os.path.isfile(p) for p in current_inner) else legacy_inner
if any(os.path.isfile(p) for p in inner):
if not all(os.path.isfile(p) for p in inner):
print ("Error! Incomplete internal Gerber layer pair: " + ", ".join(inner))
sys.exit(2)
gerbers += [ ".G1", ".G2" ]
for g in gerbers:
copied = False
gl = g.lower()
for gPath in glob.glob(src_name + "*" + gl):
paths = [inner[int(g[2:]) - 1]] if g in (".G1", ".G2") else glob.glob(src_name + "*" + gl)
for gPath in paths:
print ("* Copying " + name + gl + "...")
# keepout layer is a special case
if (g == ".GM1"):
# currently the "edge cuts" layer is used as a frame border
if (type == "frames"):
shutil.copyfile(gPath, dst_name + ".GM15")
shutil.copyfile(gPath, dst_name + ".GKO")
elif (g == ".G2"):
# using Altium naming convention
shutil.copyfile(gPath, dst_name + ".G1")
elif (g == ".G3"):
# using Altium naming convention
shutil.copyfile(gPath, dst_name + ".G2")
else:
shutil.copyfile(gPath, dst_name + g)
copied = True
Expand All @@ -91,8 +95,6 @@ def mkdir_p(path):
print ("* Skipping Drill for " + name + "...")
elif (g == ".GBP"):
print ("* Skipping Bottom Paste for " + name + "...")
elif (g == ".G2" or g == ".G3"):
print ("* Skipping Internal Layer " + g + " for " + name + "...")
else:
print ("Error! Gerber " + g + " not found for " + name + "!")
sys.exit(2)
Expand Down
1 change: 1 addition & 0 deletions kicad/bin/export.sh
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ $PYTHON "$DIR/../hellen-one-kicad-bom-plugin.py" "$NET_FILE" "$OUT_FOLDER/$IN.cs

echo Export Gerbers with zones refilled in-memory
$KICAD_CLI pcb export gerbers --disable-aperture-macros -l "F.Cu,B.Cu,F.Paste,B.Paste,F.SilkS,B.SilkS,F.Mask,B.Mask,Edge.Cuts,In2.Cu,In1.Cu" --no-x2 --use-drill-file-origin --check-zones "$PCB_FILE" -o $OUT_FOLDER/
$PYTHON "$DIR/../../bin/cleanup_kicad_gerbers.py" "$OUT_FOLDER" "$IN"
echo Export drill file
$KICAD_CLI pcb export drill --map-format ps --drill-origin plot --excellon-zeros-format suppressleading -u "in" "$PCB_FILE" -o $OUT_FOLDER/
echo Export positions file
Expand Down
13 changes: 13 additions & 0 deletions kicad/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,16 @@ Local run for the test frame:
```
cd tests && bash ../kicad/bin/export.sh
```

KiCad 8 named the inner layers `In1_Cu.g2` / `In2_Cu.g3`; KiCad 10 uses
`In1_Cu.g1` / `In2_Cu.g2`. Export automatically removes the obsolete filenames
when their replacements exist. To clean an existing board export manually:

```
python3 hellen-one/bin/cleanup_kicad_gerbers.py gerber uaefi-pro --dry-run
python3 hellen-one/bin/cleanup_kicad_gerbers.py gerber uaefi-pro
```

The copy step selects inner layers by full layer name and prefers the KiCad 10
pair, while still accepting a KiCad 8 pair. Regenerate the board outputs after
cleaning an export that contained both versions.
95 changes: 95 additions & 0 deletions tests/test_kicad_gerbers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Regression checks for mixed KiCad 8 and KiCad 10 inner-layer exports."""

from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest


ROOT = Path(__file__).resolve().parents[1]


class KicadGerberTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix="kicad gerbers ")
self.addCleanup(self.temp.cleanup)
self.project = Path(self.temp.name)
self.gerber = self.project / "gerber"
shutil.copytree(ROOT / "tests/gerber", self.gerber)

def write_layers(self, version):
for layer in (1, 2):
extension = layer if version == 10 else layer + 1
(self.gerber / ("hellen1test-In%d_Cu.g%d" % (layer, extension))).write_text(
"KiCad %d layer %d" % (version, layer))

def run_script(self, script, *args):
return subprocess.run(
[sys.executable, str(ROOT / "bin" / script), *map(str, args)],
cwd=ROOT, capture_output=True, text=True)

def copy_frame(self):
return self.run_script("copy_from_Kicad.py", "frames:hellen", self.project,
"../../gerber", "1test", "a", "4")

def assert_layers(self, version):
result = self.copy_frame()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
frame = self.project / "boards/hellen1test-a/frame"
for layer in (1, 2):
self.assertEqual((frame / ("1test.G%d" % layer)).read_text(),
"KiCad %d layer %d" % (version, layer))

def test_legacy_export(self):
self.write_layers(8)
self.assert_layers(8)

def test_current_export(self):
self.write_layers(10)
self.assert_layers(10)

def test_mixed_exports_prefer_current_in_either_creation_order(self):
for versions in ((8, 10), (10, 8)):
with self.subTest(versions=versions):
for path in self.gerber.glob("*-In*_Cu.g*"):
path.unlink()
for version in versions:
self.write_layers(version)
self.assert_layers(10)

def test_incomplete_current_export_does_not_mix_versions(self):
self.write_layers(8)
(self.gerber / "hellen1test-In1_Cu.g1").write_text("new layer 1")
result = self.copy_frame()
self.assertEqual(result.returncode, 2)
self.assertIn("Incomplete internal Gerber layer pair", result.stdout)

def test_cleanup_is_scoped_and_repeatable(self):
self.write_layers(8)
self.write_layers(10)
unrelated = self.gerber / "other-In1_Cu.g2"
unrelated.write_text("another board")
before = {p.name: p.read_bytes() for p in self.gerber.iterdir()}
result = self.run_script("cleanup_kicad_gerbers.py", self.gerber,
"hellen1test", "--dry-run")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(before, {p.name: p.read_bytes() for p in self.gerber.iterdir()})
for _ in range(2):
result = self.run_script("cleanup_kicad_gerbers.py", self.gerber, "hellen1test")
self.assertEqual(result.returncode, 0, result.stderr)
for obsolete in ("hellen1test-In1_Cu.g2", "hellen1test-In2_Cu.g3"):
del before[obsolete]
self.assertEqual(before, {p.name: p.read_bytes() for p in self.gerber.iterdir()})
self.assert_layers(10)

def test_cleanup_preserves_legacy_only_export(self):
self.write_layers(8)
result = self.run_script("cleanup_kicad_gerbers.py", self.gerber, "hellen1test")
self.assertEqual(result.returncode, 0, result.stderr)
self.assert_layers(8)


if __name__ == "__main__":
unittest.main()
Loading