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