217 lines
6.9 KiB
Python
217 lines
6.9 KiB
Python
#!/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())
|