From 1913f951b657f9d264fe5c427af2a43b6a0c0091 Mon Sep 17 00:00:00 2001 From: rusefillc Date: Thu, 10 Sep 2026 09:47:02 -0400 Subject: [PATCH] KiCad 10 fix (some file names have changed) --- .github/workflows/test-check-all.yaml | 3 + bin/cleanup_kicad_gerbers.py | 33 ++++++++++ bin/copy_from_Kicad.py | 22 ++++--- kicad/bin/export.sh | 1 + kicad/readme.md | 13 ++++ tests/test_kicad_gerbers.py | 95 +++++++++++++++++++++++++++ 6 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 bin/cleanup_kicad_gerbers.py create mode 100644 tests/test_kicad_gerbers.py diff --git a/.github/workflows/test-check-all.yaml b/.github/workflows/test-check-all.yaml index 5ee88cf..af60284 100644 --- a/.github/workflows/test-check-all.yaml +++ b/.github/workflows/test-check-all.yaml @@ -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 diff --git a/bin/cleanup_kicad_gerbers.py b/bin/cleanup_kicad_gerbers.py new file mode 100644 index 0000000..91b9df0 --- /dev/null +++ b/bin/cleanup_kicad_gerbers.py @@ -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() diff --git a/bin/copy_from_Kicad.py b/bin/copy_from_Kicad.py index 4ef9c8c..d654785 100644 --- a/bin/copy_from_Kicad.py +++ b/bin/copy_from_Kicad.py @@ -65,11 +65,21 @@ 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"): @@ -77,12 +87,6 @@ def mkdir_p(path): 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 @@ -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) diff --git a/kicad/bin/export.sh b/kicad/bin/export.sh index 19c7f00..e16ede5 100755 --- a/kicad/bin/export.sh +++ b/kicad/bin/export.sh @@ -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 diff --git a/kicad/readme.md b/kicad/readme.md index f934852..2f6812b 100644 --- a/kicad/readme.md +++ b/kicad/readme.md @@ -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. diff --git a/tests/test_kicad_gerbers.py b/tests/test_kicad_gerbers.py new file mode 100644 index 0000000..b4038dd --- /dev/null +++ b/tests/test_kicad_gerbers.py @@ -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()