#!/usr/bin/env python3 """ Load two Protel PCB 2.8 ASCII files and report which components have moved between them. Component position is taken as the centroid of pin coordinates. All paths: use .env (FILE1, FILE2, COMPARE_OUTPUT) or CLI; CLI overrides .env. Usage: python3 compare_protel_locations.py file1.pcb file2.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 parse_components_with_positions(path: str) -> dict[str, dict]: """ Parse a Protel PCB 2.8 ASCII file and return a dict: designator -> { "x": float, "y": float, "pins": [(x,y), ...], "pattern": str, "value": str } Position is the centroid of all pin coordinates (from the numeric line after each PIN line). """ text = Path(path).read_text(encoding="utf-8", errors="replace") lines = [line.rstrip() for line in text.splitlines()] components: dict[str, dict] = {} i = 0 while i < len(lines): if lines[i].strip().upper() != "COMP": i += 1 continue i += 1 designator = "" pattern = "" value = "" pin_coords: list[tuple[float, float]] = [] while i < len(lines): ln = lines[i] if ln.strip().upper() == "ENDCOMP": i += 1 break if not designator and ln.strip(): parts = ln.strip().split() if parts and not re.match( r"^(PATTERN|VALUE|PIN|PINNE|ENDCOMP)$", parts[0], re.I ): designator = parts[0] m = re.match(r"^\s*PATTERN\s+(.+)$", ln, re.I) if m: pattern = m.group(1).strip().strip('"') m = re.match(r"^\s*VALUE\s+(.+)$", ln, re.I) if m: value = m.group(1).strip().strip('"') if re.match(r"^\s*(?:PINNE|PIN)\s+", ln, re.I): # Next line often has coordinates: first two numbers are X Y if i + 1 < len(lines): next_ln = lines[i + 1].strip().split() nums = [t for t in next_ln if re.match(r"^-?\d+$", t)] if len(nums) >= 2: try: x, y = int(nums[0]), int(nums[1]) pin_coords.append((float(x), float(y))) except ValueError: pass i += 1 i += 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 "?", } 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 "output/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 Protel PCB ASCII files and list components that moved" ) parser.add_argument( "file1", nargs="?", default=default_file1, help="First PCB file (default: FILE1 from .env)", ) parser.add_argument( "file2", nargs="?", default=default_file2, help="Second PCB file (default: FILE2 from .env)", ) parser.add_argument( "-o", "--output", default=default_output, help="Output JSON path (default: COMPARE_OUTPUT from .env or output/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 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())