#!/usr/bin/env python3 """ Load two KiCad .kicad_pcb files and report which components have moved between them. Component position is the centroid of pad (at x y) coordinates. All paths: use .env (FILE1, FILE2, COMPARE_OUTPUT) or CLI; CLI overrides .env. Usage: python3 compare_protel_locations.py file1.kicad_pcb file2.kicad_pcb [-o report.json] python3 compare_protel_locations.py # uses FILE1, FILE2 from .env """ import argparse import json import math import os import re import sys from pathlib import Path try: from dotenv import load_dotenv load_dotenv() except ImportError: pass def _find_matching_paren(text: str, start: int) -> int: """Return index of closing paren for the ( at start.""" depth = 0 for i in range(start, len(text)): if text[i] == "(": depth += 1 elif text[i] == ")": depth -= 1 if depth == 0: return i return -1 def parse_components_with_positions(path: str) -> dict[str, dict]: """ Parse a KiCad .kicad_pcb file and return a dict: designator -> { "x": float, "y": float, "pins": [(x,y), ...], "pattern": str, "value": str } Position is the centroid of all pad (at x y) coordinates. """ text = Path(path).read_text(encoding="utf-8", errors="replace") components: dict[str, dict] = {} fp_pos = 0 while True: fp_start = text.find("(footprint ", fp_pos) if fp_start < 0: break quote = text.find('"', fp_start + 10) if quote < 0: break quote_end = text.find('"', quote + 1) if quote_end < 0: break lib_name = text[quote + 1 : quote_end] fp_end = _find_matching_paren(text, fp_start) if fp_end < 0: break rest = text[fp_start:fp_end + 1] pattern = lib_name.split(":")[-1] if ":" in lib_name else lib_name ref_m = re.search(r'\(\s*fp_text\s+reference\s+"([^"]+)"', rest) value_m = re.search(r'\(\s*fp_text\s+value\s+"([^"]+)"', rest) designator = ref_m.group(1) if ref_m else "" value = value_m.group(1) if value_m else "?" pin_coords: list[tuple[float, float]] = [] pos = 0 while True: pad_start = rest.find("(pad ", pos) if pad_start < 0: break pad_end = _find_matching_paren(rest, pad_start) if pad_end < 0: break block = rest[pad_start:pad_end + 1] at_m = re.search(r'\(\s*at\s+([-\d.]+)\s+([-\d.]+)', block) if at_m: try: x, y = float(at_m.group(1)), float(at_m.group(2)) pin_coords.append((x, y)) except ValueError: pass pos = pad_end + 1 if designator and pin_coords: cx = sum(p[0] for p in pin_coords) / len(pin_coords) cy = sum(p[1] for p in pin_coords) / len(pin_coords) components[designator] = { "x": round(cx, 2), "y": round(cy, 2), "pins": pin_coords, "pattern": pattern or "", "value": value or "?", } fp_pos = fp_end + 1 return components def distance(x1: float, y1: float, x2: float, y2: float) -> float: return math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) def main() -> int: default_file1 = os.environ.get("FILE1", "").strip() or None default_file2 = os.environ.get("FILE2", "").strip() or None default_output = ( os.environ.get("COMPARE_OUTPUT", "").strip() or "outputs/compare_locations.json" ) default_threshold = os.environ.get("THRESHOLD", "").strip() try: default_threshold = float(default_threshold) if default_threshold else 1.0 except ValueError: default_threshold = 1.0 parser = argparse.ArgumentParser( description="Compare two KiCad .kicad_pcb files and list components that moved" ) parser.add_argument( "file1", nargs="?", default=default_file1, help="First .kicad_pcb file (default: FILE1 from .env)", ) parser.add_argument( "file2", nargs="?", default=default_file2, help="Second .kicad_pcb file (default: FILE2 from .env)", ) parser.add_argument( "-o", "--output", default=default_output, help="Output JSON path (default: COMPARE_OUTPUT from .env or outputs/compare_locations.json)", ) parser.add_argument( "--threshold", type=float, default=default_threshold, help="Minimum position change to count as moved (default: THRESHOLD from .env or 1.0)", ) args = parser.parse_args() path1 = (args.file1 or default_file1 or "").strip() path2 = (args.file2 or default_file2 or "").strip() if not path1 or not path2: parser.error( "Need two .kicad_pcb files. Set FILE1 and FILE2 in .env or pass two paths." ) if not Path(path1).exists(): print(f"Error: file not found: {path1}", file=sys.stderr) return 1 if not Path(path2).exists(): print(f"Error: file not found: {path2}", file=sys.stderr) return 1 try: comp1 = parse_components_with_positions(path1) comp2 = parse_components_with_positions(path2) except Exception as e: print(f"Parse error: {e}", file=sys.stderr) return 1 # Find components that appear in both and have moved moved = [] only_in_file1 = [] only_in_file2 = [] for des, pos1 in comp1.items(): if des not in comp2: only_in_file1.append(des) continue pos2 = comp2[des] dist = distance(pos1["x"], pos1["y"], pos2["x"], pos2["y"]) if dist > args.threshold: moved.append({ "designator": des, "file1": {"x": pos1["x"], "y": pos1["y"]}, "file2": {"x": pos2["x"], "y": pos2["y"]}, "distance": round(dist, 2), "pattern": pos1.get("pattern", ""), "value": pos1.get("value", "?"), }) for des in comp2: if des not in comp1: only_in_file2.append(des) report = { "file1": path1, "file2": path2, "threshold": args.threshold, "moved_count": len(moved), "moved": moved, "only_in_file1": sorted(only_in_file1), "only_in_file2": sorted(only_in_file2), } out_path = Path(args.output) out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text(json.dumps(report, indent=2), encoding="utf-8") print(f"Wrote {args.output}") print(f"Moved: {len(moved)} | Only in file1: {len(only_in_file1)} | Only in file2: {len(only_in_file2)}") if moved: for m in moved: print(f" {m['designator']}: ({m['file1']['x']},{m['file1']['y']}) -> ({m['file2']['x']},{m['file2']['y']}) dist={m['distance']}") return 0 if __name__ == "__main__": sys.exit(main())