Add compare locations script for two Protel files

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-11 12:10:12 +13:00
parent 1c02165b5e
commit 8dfc0042b1

202
compare_protel_locations.py Normal file
View File

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