Files
altium-scripts/locations.py

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())