From 8dfc0042b1a72abc193227791d8767abc96d0b35 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Wed, 11 Feb 2026 12:10:12 +1300 Subject: [PATCH] Add compare locations script for two Protel files Co-authored-by: Cursor --- compare_protel_locations.py | 202 ++++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 compare_protel_locations.py diff --git a/compare_protel_locations.py b/compare_protel_locations.py new file mode 100644 index 0000000..6646d00 --- /dev/null +++ b/compare_protel_locations.py @@ -0,0 +1,202 @@ +#!/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. + +Input/output paths can be set in .env (FILE1, FILE2, COMPARE_OUTPUT); CLI overrides. + +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" + ) + + 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=1.0, + help="Minimum position change to count as moved (default: 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())