diff --git a/.env.example b/.env.example index 08d0193..f90a159 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,12 @@ # All scripts: set vars in .env or pass via CLI; CLI overrides .env. -# Capacitors by net pair -INPUT_FILE=board.pcb +# Capacitors by net pair (KiCad .kicad_pcb only) +INPUT_FILE=board.kicad_pcb OUTPUT_FILE=outputs/capacitors_by_net_pair.json -# Compare Protel locations -FILE1=board_v1.pcb -FILE2=board_v2.pcb +# Compare KiCad locations (.kicad_pcb) +FILE1=board_v1.kicad_pcb +FILE2=board_v2.kicad_pcb COMPARE_OUTPUT=outputs/compare_locations.json THRESHOLD=1.0 diff --git a/README.md b/README.md index afaeccb..3f7fa6f 100644 --- a/README.md +++ b/README.md @@ -59,35 +59,27 @@ Finds all **two-pad components** on the PCB that share the same two nets (e.g. d } ``` -### Protel PCB 2.8 ASCII — easier (Python, no Altium) +### KiCad .kicad_pcb (Python script) -**Yes — Protel PCB 2.8 ASCII is easier.** It’s plain text, so you can parse it with Python and no OLE/binary handling. You don’t need Altium running. +**Script:** `capacitors_by_net_pair.py` — **KiCad only.** Reads a `.kicad_pcb` file and outputs the same JSON (net pair → capacitors with designator, value, package, total capacitance). -1. **Export from Altium:** Open your PcbDoc → **File → Save As** (or **Export**) → choose **PCB 2.8 ASCII** or **Protel PCB ASCII** if your version offers it. Some versions use **File → Save Copy As** with format “PCB Binary/ASCII” or similar. -2. **Run the Python script** on the exported `.pcb` / `.PcbDoc` (ASCII) file: - - ```bash - python3 capacitors_by_net_pair.py board.PcbDoc - python3 capacitors_by_net_pair.py board.PcbDoc -o out.json - ``` - - **Input/output from .env:** Copy `.env.example` to `.env` and set `INPUT_FILE` and `OUTPUT_FILE`. The script reads these when the optional `python-dotenv` package is installed; CLI arguments override them. Without `.env`, you can still pass the input file and `-o` on the command line. By default the JSON is written to **`outputs/capacitors_by_net_pair.json`** (the `outputs/` directory is created if needed). - -See **`capacitors_by_net_pair.py`** for the script. It parses COMP/PATTERN/VALUE and NET/PIN data from the ASCII file and produces the same JSON shape as the DelphiScript. - -**Test file:** `tests/sample_protel_ascii.pcb` is a minimal Protel PCB 2.8 ASCII sample. Run: +**Usage:** ```bash -python3 capacitors_by_net_pair.py tests/sample_protel_ascii.pcb -o tests/out.json +python3 capacitors_by_net_pair.py board.kicad_pcb +python3 capacitors_by_net_pair.py board.kicad_pcb -o outputs/capacitors_by_net_pair.json ``` +**Input/output from .env:** Set `INPUT_FILE` and `OUTPUT_FILE`; CLI overrides. Default output: **`outputs/capacitors_by_net_pair.json`**. + + --- -## Compare component locations (two Protel files) +## Compare component locations (two KiCad files) **Script:** `compare_protel_locations.py` -Loads two Protel PCB 2.8 ASCII files and reports **which components have moved** between them. Component position is the centroid of pin coordinates. Output is written to `outputs/compare_locations.json` by default. +Loads two KiCad `.kicad_pcb` files and reports **which components have moved** between them. Component position is the centroid of pad `(at x y)` coordinates. Output is written to `outputs/compare_locations.json` by default. - **Moved:** designators with different (x, y) in file2, with old position, new position, and distance. - **Only in file1 / only in file2:** components that appear in just one file. @@ -95,18 +87,12 @@ Loads two Protel PCB 2.8 ASCII files and reports **which components have moved** **Usage:** ```bash -python3 compare_protel_locations.py board_v1.pcb board_v2.pcb -python3 compare_protel_locations.py board_v1.pcb board_v2.pcb -o outputs/compare_locations.json +python3 compare_protel_locations.py board_v1.kicad_pcb board_v2.kicad_pcb +python3 compare_protel_locations.py board_v1.kicad_pcb board_v2.kicad_pcb -o outputs/compare_locations.json ``` Use **.env** (optional): set `FILE1`, `FILE2`, and `COMPARE_OUTPUT`; CLI arguments override them. Use `--threshold N` to set the minimum position change to count as moved (default 1.0). -**Test:** `tests/sample_protel_ascii.pcb` and `tests/sample_protel_ascii_rev2.pcb` (C1 and C2 moved in rev2): - -```bash -python3 compare_protel_locations.py tests/sample_protel_ascii.pcb tests/sample_protel_ascii_rev2.pcb -``` - --- ## Spreadsheet diff by designator diff --git a/find_bottom_termination_parts.py b/bottom_termination.py similarity index 100% rename from find_bottom_termination_parts.py rename to bottom_termination.py diff --git a/capacitors_by_net_pair.py b/capacitors.py similarity index 54% rename from capacitors_by_net_pair.py rename to capacitors.py index 08f81e9..bd146b6 100644 --- a/capacitors_by_net_pair.py +++ b/capacitors.py @@ -1,14 +1,13 @@ #!/usr/bin/env python3 """ -Parse Protel PCB 2.8 ASCII (or Protel 99 SE PCB ASCII) and output capacitors -by net pair: JSON with net pair as key, designator/value/package and total -capacitance per net pair. +Parse KiCad .kicad_pcb and output capacitors by net pair: JSON with net pair +as key, designator/value/package and total capacitance per net pair. All paths: use .env (INPUT_FILE, OUTPUT_FILE) or CLI; CLI overrides .env. Usage: - python capacitors_by_net_pair.py [file.pcb] [-o output.json] - # or set in .env: INPUT_FILE=board.pcb, OUTPUT_FILE=out.json + python capacitors_by_net_pair.py [file.kicad_pcb] [-o output.json] + # or set in .env: INPUT_FILE=board.kicad_pcb, OUTPUT_FILE=out.json """ import argparse @@ -74,100 +73,87 @@ def format_capacitance(cap_f: float) -> str: return f"{cap_f}F" -# --- Protel PCB 2.8 ASCII parsing -------------------------------------------- +# --- KiCad .kicad_pcb parsing ------------------------------------------------- -def parse_protel_ascii(path: str) -> tuple[list[dict], dict]: +def _find_matching_paren(text: str, start: int) -> int: + """Return index of closing paren for the ( at start.""" + depth = 0 + for i in range(start, len(text)): + if text[i] == "(": + depth += 1 + elif text[i] == ")": + depth -= 1 + if depth == 0: + return i + return -1 + + +def parse_kicad_pcb(path: str) -> tuple[list[dict], dict]: """ - Parse file and return (components, pin_to_net). - components: list of { - "designator": str, - "pattern": str, - "value": str, - "pins": list of (pin_id, net_name or None), - } - pin_to_net: (designator, pin_id) -> net_name (from NET section if present). + Parse KiCad .kicad_pcb (s-expression) and return (components, pin_to_net). + components: designator, pattern, value, pins (with net names from pad net). + pin_to_net is empty (unused for KiCad). """ text = Path(path).read_text(encoding="utf-8", errors="replace") - lines = [line.rstrip() for line in text.splitlines()] - - # NET section: map (Designator, PinNumber) -> NetName - # Formats: "NET" "NetName" then "C1-1" / C1-1; or NetName then Designator-Pin lines - pin_to_net: dict[tuple[str, str], str] = {} - i = 0 - while i < len(lines): - line = lines[i] - rest = line.strip() - # "NET" "NetName" or NET NetName - if re.match(r"^\s*NET\s+", line, re.I): - parts = re.split(r"\s+", rest, maxsplit=2) - current_net = (parts[1].strip('"') if len(parts) > 1 else "") or None - i += 1 - while i < len(lines): - conn = lines[i].strip().strip('"') - if re.match(r"^[A-Za-z0-9_]+-\d+$", conn) and current_net: - comp, pin = conn.split("-", 1) - pin_to_net[(comp.upper(), pin)] = current_net - i += 1 - elif conn.upper() in ("ENDNET", "END", ""): - break - else: - i += 1 - continue - i += 1 - - # COMP ... ENDCOMP blocks + # Net list: (net N "Name") + net_by_num: dict[int, str] = {} + for m in re.finditer(r'\(\s*net\s+(\d+)\s+"([^"]*)"\s*\)', text, re.I): + net_by_num[int(m.group(1))] = m.group(2) + # Footprint blocks: find each (footprint "Name" and its matching ) components: list[dict] = [] - i = 0 - while i < len(lines): - line = lines[i] - if line.strip().upper() != "COMP": - i += 1 - continue - i += 1 - designator = "" - pattern = "" - value = "" - pins: list[tuple[str, str | None]] = [] # (pin_id, net_name) - - while i < len(lines): - ln = lines[i] - if ln.strip().upper() == "ENDCOMP": - i += 1 + fp_pos = 0 + while True: + fp_start = text.find("(footprint ", fp_pos) + if fp_start < 0: + break + quote = text.find('"', fp_start + 10) + if quote < 0: + break + quote_end = text.find('"', quote + 1) + if quote_end < 0: + break + lib_name = text[quote + 1 : quote_end] + fp_end = _find_matching_paren(text, fp_start) + if fp_end < 0: + break + rest = text[fp_start:fp_end + 1] + pattern = lib_name.split(":")[-1] if ":" in lib_name else lib_name + ref_m = re.search(r'\(\s*fp_text\s+reference\s+"([^"]+)"', rest) + value_m = re.search(r'\(\s*fp_text\s+value\s+"([^"]+)"', rest) + designator = ref_m.group(1) if ref_m else "" + value = value_m.group(1) if value_m else "?" + pins: list[tuple[str, str | None]] = [] + # Find each (pad "N" ... and the (net num "Name") inside that pad's span + pos = 0 + while True: + pad_start = rest.find("(pad ", pos) + if pad_start < 0: break - # Designator: first token on first line after COMP that isn't PATTERN/VALUE/PIN/PINNE - if not designator and ln.strip(): - parts = ln.strip().split() - if parts: - candidate = parts[0] - if not re.match(r"^(PATTERN|VALUE|PIN|PINNE|ENDCOMP)$", candidate, re.I): - designator = candidate - # PATTERN = value or PATTERN value - 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('"') - # PIN: PIN [net] ... or PINNE ... (numbers follow) - pin_match = re.match(r"^\s*(?:PINNE|PIN)\s+(\S+)\s+(\S+)", ln, re.I) - if pin_match: - pin_name, second = pin_match.groups() - net_name = second if not second.replace(".", "").isdigit() else None - if net_name and net_name.upper() in ("PINNE", "PIN"): - net_name = None - if not net_name and (designator, pin_name) in pin_to_net: - net_name = pin_to_net[(designator, pin_name)] - elif not net_name and (designator.upper(), pin_name) in pin_to_net: - net_name = pin_to_net[(designator.upper(), pin_name)] - pins.append((pin_name, net_name)) + quote = rest.find('"', pad_start + 5) + if quote < 0: + break + quote_end = rest.find('"', quote + 1) + if quote_end < 0: + break + pad_num = rest[quote + 1 : quote_end] + depth = 0 + end = pad_start + 4 + for i, c in enumerate(rest[pad_start:], start=pad_start): + if c == "(": + depth += 1 + elif c == ")": + depth -= 1 + if depth == 0: + end = i + break + block = rest[pad_start:end] + net_m = re.search(r'\(\s*net\s+(\d+)\s+"([^"]*)"\s*\)', block) + if net_m: + net_name = net_m.group(2) or net_by_num.get(int(net_m.group(1)), "") else: - pin_simple = re.match(r"^\s*(?:PINNE|PIN)\s+(\S+)", ln, re.I) - if pin_simple: - pn = pin_simple.group(1) - net_name = pin_to_net.get((designator, pn)) or pin_to_net.get((designator.upper(), pn)) - pins.append((pn, net_name)) - i += 1 - + net_name = net_by_num.get(0, "") if 0 in net_by_num else None + pins.append((pad_num, net_name or None)) + pos = end + 1 if designator: components.append({ "designator": designator, @@ -175,7 +161,8 @@ def parse_protel_ascii(path: str) -> tuple[list[dict], dict]: "value": value or "?", "pins": pins, }) - return components, pin_to_net + fp_pos = fp_end + 1 + return components, {} def build_net_key(net1: str, net2: str) -> str: @@ -189,12 +176,12 @@ def main() -> int: default_input = os.environ.get("INPUT_FILE", "").strip() or None default_output = os.environ.get("OUTPUT_FILE", "").strip() or "outputs/capacitors_by_net_pair.json" - parser = argparse.ArgumentParser(description="List capacitors by net pair from Protel PCB 2.8 ASCII") + parser = argparse.ArgumentParser(description="List capacitors by net pair from KiCad .kicad_pcb") parser.add_argument( "file", nargs="?", default=default_input, - help="Path to .pcb / .PcbDoc (ASCII) file (default: INPUT_FILE from .env)", + help="Path to .kicad_pcb file (default: INPUT_FILE from .env)", ) parser.add_argument( "-o", "--output", @@ -209,7 +196,7 @@ def main() -> int: parser.error("No input file. Set INPUT_FILE in .env or pass the file path as an argument.") try: - components, pin_to_net = parse_protel_ascii(input_path) + components, pin_to_net = parse_kicad_pcb(input_path) except Exception as e: print(f"Parse error: {e}", file=sys.stderr) return 1 diff --git a/compare_protel_locations.py b/locations.py similarity index 66% rename from compare_protel_locations.py rename to locations.py index 43aba21..5874f8a 100644 --- a/compare_protel_locations.py +++ b/locations.py @@ -1,12 +1,12 @@ #!/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. +Load two KiCad .kicad_pcb files and report which components have moved +between them. Component position is the centroid of pad (at x y) 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 file1.kicad_pcb file2.kicad_pcb [-o report.json] python3 compare_protel_locations.py # uses FILE1, FILE2 from .env """ @@ -25,58 +25,66 @@ except ImportError: pass +def _find_matching_paren(text: str, start: int) -> int: + """Return index of closing paren for the ( at start.""" + depth = 0 + for i in range(start, len(text)): + if text[i] == "(": + depth += 1 + elif text[i] == ")": + depth -= 1 + if depth == 0: + return i + return -1 + + def parse_components_with_positions(path: str) -> dict[str, dict]: """ - Parse a Protel PCB 2.8 ASCII file and return a dict: + Parse a KiCad .kicad_pcb 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). + Position is the centroid of all pad (at x y) coordinates. """ 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 = "" + fp_pos = 0 + while True: + fp_start = text.find("(footprint ", fp_pos) + if fp_start < 0: + break + quote = text.find('"', fp_start + 10) + if quote < 0: + break + quote_end = text.find('"', quote + 1) + if quote_end < 0: + break + lib_name = text[quote + 1 : quote_end] + fp_end = _find_matching_paren(text, fp_start) + if fp_end < 0: + break + rest = text[fp_start:fp_end + 1] + pattern = lib_name.split(":")[-1] if ":" in lib_name else lib_name + ref_m = re.search(r'\(\s*fp_text\s+reference\s+"([^"]+)"', rest) + value_m = re.search(r'\(\s*fp_text\s+value\s+"([^"]+)"', rest) + designator = ref_m.group(1) if ref_m else "" + value = value_m.group(1) if value_m else "?" pin_coords: list[tuple[float, float]] = [] - - while i < len(lines): - ln = lines[i] - if ln.strip().upper() == "ENDCOMP": - i += 1 + pos = 0 + while True: + pad_start = rest.find("(pad ", pos) + if pad_start < 0: 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 - + pad_end = _find_matching_paren(rest, pad_start) + if pad_end < 0: + break + block = rest[pad_start:pad_end + 1] + at_m = re.search(r'\(\s*at\s+([-\d.]+)\s+([-\d.]+)', block) + if at_m: + try: + x, y = float(at_m.group(1)), float(at_m.group(2)) + pin_coords.append((x, y)) + except ValueError: + pass + pos = pad_end + 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) @@ -87,6 +95,7 @@ def parse_components_with_positions(path: str) -> dict[str, dict]: "pattern": pattern or "", "value": value or "?", } + fp_pos = fp_end + 1 return components @@ -108,19 +117,19 @@ def main() -> int: default_threshold = 1.0 parser = argparse.ArgumentParser( - description="Compare two Protel PCB ASCII files and list components that moved" + description="Compare two KiCad .kicad_pcb files and list components that moved" ) parser.add_argument( "file1", nargs="?", default=default_file1, - help="First PCB file (default: FILE1 from .env)", + help="First .kicad_pcb file (default: FILE1 from .env)", ) parser.add_argument( "file2", nargs="?", default=default_file2, - help="Second PCB file (default: FILE2 from .env)", + help="Second .kicad_pcb file (default: FILE2 from .env)", ) parser.add_argument( "-o", @@ -140,7 +149,7 @@ def main() -> int: 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." + "Need two .kicad_pcb files. Set FILE1 and FILE2 in .env or pass two paths." ) if not Path(path1).exists():