Add compare locations script for two Protel files
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
202
compare_protel_locations.py
Normal file
202
compare_protel_locations.py
Normal 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())
|
||||
Reference in New Issue
Block a user