diff --git a/backend/pinscopex/graph.py b/backend/pinscopex/graph.py index 068cbaf..cb20d44 100644 --- a/backend/pinscopex/graph.py +++ b/backend/pinscopex/graph.py @@ -254,6 +254,7 @@ def build_graph( mpn_col: str = "Manufacturer Part Number", skipped: list[SkippedItem] | None = None, include_subdesigns: set[str] | None = None, + pcb_path: str | Path | None = None, ) -> DesignGraph: """Build a DesignGraph deterministically from project files. @@ -264,6 +265,9 @@ def build_graph( 4. Resolve passive specs from patterns + cached component models 5. Assemble components with classified type, linked constraints, and specs 6. Assemble nets with inferred type/voltage and enriched pin names + + When ``pcb_path`` points at a ``.kicad_pcb``, pad nets from the board replace + schematic-derived connectivity (KiCad board nets are authoritative). """ # Parse BOM first so we can feed known refs into the netlist parser — # PADS-PCB netlists allow multi-word designators (e.g. "CV GND"), which @@ -284,6 +288,17 @@ def build_graph( known_refs=set(bom.keys()), include_subdesigns=include_subdesigns, ) + if pcb_path is not None: + pcb = Path(pcb_path) + if pcb.is_file(): + from backend.pinscopex.parsers_kicad_pcb import nets_from_pcb, parse_kicad_pcb + + layout = parse_kicad_pcb(pcb) + pcb_nets = nets_from_pcb(layout) + if pcb_nets: + raw_nets = pcb_nets + for ref, fp in layout.footprints.items(): + parts.setdefault(ref, fp.footprint or "") if fmt.startswith("kicad"): from backend.pinscopex.parsers_kicad import kicad_part_fields for ref, extra in kicad_part_fields(netlist_path).items(): diff --git a/backend/pinscopex/parsers_kicad_pcb.py b/backend/pinscopex/parsers_kicad_pcb.py index 238223c..4252976 100644 --- a/backend/pinscopex/parsers_kicad_pcb.py +++ b/backend/pinscopex/parsers_kicad_pcb.py @@ -45,16 +45,62 @@ def _prop(node: object, key: str) -> str: def _pad_net(pad: object) -> str: n = _kid(pad, "net") - if n and len(n) >= 3: + if not n or len(n) < 2: + return "" + # KiCad 9/10 often stores ``(net "GND")`` without a numeric code. + if len(n) == 2 and not isinstance(n[1], list): + return str(n[1]) + # Legacy ``(net 3 "GND")``. + if len(n) >= 3: return str(n[2]) return "" +def _normalize_pcb_net_name(name: str) -> str: + """Strip KiCad root-sheet ``/`` prefixes; keep empty / unconnected as-is.""" + n = (name or "").strip() + if not n: + return "" + while n.startswith("/"): + n = n[1:] + return n + + +def nets_from_pcb(layout: LayoutGraph) -> dict[str, list[tuple[str, str]]]: + """Pad connectivity from a parsed board — authoritative when sch geometry fails.""" + nets: dict[str, list[tuple[str, str]]] = {} + seen: set[tuple[str, str, str]] = set() + for ref, fp in layout.footprints.items(): + for pad in fp.pads: + raw = pad.net or "" + if not raw: + continue + name = _normalize_pcb_net_name(raw) + if not name: + continue + key = (name, ref, pad.number) + if key in seen: + continue + seen.add(key) + nets.setdefault(name, []).append((ref, pad.number)) + return nets + + def _net_name(node: object, nets: dict[str, int]) -> str: n = _kid(node, "net") if not n or len(n) < 2: named = _val(node, "net_name") return named + # ``(net "GND")`` or ``(net 1)`` or ``(net 1 "GND")`` + if len(n) == 2 and not isinstance(n[1], list): + token = n[1] + if isinstance(token, str) and not str(token).replace(".", "", 1).isdigit(): + return str(token) + try: + code = int(_fnum(token)) + except (TypeError, ValueError): + return str(token) + return next((name for name, c in nets.items() if c == code), str(code)) if len(n) >= 3: return str(n[2]) try: diff --git a/backend/services/pipeline.py b/backend/services/pipeline.py index 0603821..007c596 100644 --- a/backend/services/pipeline.py +++ b/backend/services/pipeline.py @@ -1565,6 +1565,7 @@ async def _stage_graph_build(ctx: PipelineContext) -> None: if ctx.meta.netlist_subdesigns is not None else None ), + pcb_path=ctx.ws.local_path("uploads/pcb.kicad_pcb"), ) graph_path = ctx.ws.local_path("design_graph.json") @@ -2130,6 +2131,7 @@ async def run_regen_pipeline( if meta.netlist_subdesigns is not None else None ), + pcb_path=ws.local_path("uploads/pcb.kicad_pcb"), ) graph_path = ws.local_path("design_graph.json") diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index 8fd3a06..83f5eb0 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,14 @@ What's new in Pinscope. +## 2.26.5 — 2026-09-11 — Prefer PCB nets for connectivity + +When a `.kicad_pcb` is present, pad nets from the board drive the design graph. Schematic geometry alone was inventing swapped rails (GND↔3V3) and floating pins on good KiCad 10 designs. + +- [Fixed] KiCad 10 `(net "Name")` pad form. +- [Fixed] Graph build overrides sch nets with board connectivity; `/NET` → `NET`. +- [Test] PCB nets beat a misleading schematic. + ## 2.26.4 — 2026-09-11 — KiCad power nets merge `.kicad_sch` connectivity treated every `GND` power flag as its own island (`GND_`, `GND__`…), so the reviewer invented swapped rails and floating pins on good designs. diff --git a/tests/test_kicad_pcb.py b/tests/test_kicad_pcb.py index e174f82..92fc210 100644 --- a/tests/test_kicad_pcb.py +++ b/tests/test_kicad_pcb.py @@ -84,6 +84,84 @@ def test_empty_board_parses(tmp_path: Path): assert g.segments == [] +_PCB_V10 = """(kicad_pcb (version 20260206) (generator pcbnew) + (footprint "Package_TO_SOT_SMD:SOT-23-5" + (layer "F.Cu") + (at 0 0 0) + (property "Reference" "U3" (at 0 0 0) (effects (font (size 1 1)))) + (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "VSYS")) + (pad "2" smd rect (at 1 0) (size 1 1) (layers "F.Cu") (net "GND")) + (pad "5" smd rect (at 2 0) (size 1 1) (layers "F.Cu") (net "3V3_DIGITAL")) + ) + (footprint "RF_Module:ESP" + (layer "F.Cu") + (at 10 0 0) + (property "Reference" "U5" (at 0 0 0) (effects (font (size 1 1)))) + (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "GND")) + (pad "2" smd rect (at 1 0) (size 1 1) (layers "F.Cu") (net "3V3_DIGITAL")) + (pad "3" smd rect (at 2 0) (size 1 1) (layers "F.Cu") (net "/ESP32_EN")) + ) +) +""" + + +def test_kicad10_pad_net_string_form(tmp_path: Path): + from backend.pinscopex.parsers_kicad_pcb import nets_from_pcb + + p = tmp_path / "v10.kicad_pcb" + p.write_text(_PCB_V10) + g = parse_kicad_pcb(p) + assert g.footprints["U3"].pads[1].net == "GND" + nets = nets_from_pcb(g) + assert ("U3", "2") in nets["GND"] + assert ("U3", "5") in nets["3V3_DIGITAL"] + assert ("U5", "3") in nets["ESP32_EN"] # leading / stripped + + +def test_build_graph_prefers_pcb_nets_over_sch(tmp_path: Path): + """Board pad nets win when sch geometry would swap rails.""" + from backend.pinscopex.graph import build_graph + + sch = tmp_path / "netlist.kicad_sch" + # Minimal sch: only needs to parse as kicad_sch with some parts. + sch.write_text("""(kicad_sch (version 20250114) (uuid "1") + (lib_symbols + (symbol "Device:R" + (pin passive (at 0 3.81 90) (length 2.54) + (name "~" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))) + ) + (pin passive (at 0 -3.81 90) (length 2.54) + (name "~" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27)))) + ) + ) + ) + (symbol (lib_id "Device:R") (at 0 0 0) (unit 1) (uuid "a") + (property "Reference" "R1" (at 0 0 0) (effects (font (size 1.27 1.27)))) + (property "Value" "10k" (at 0 0 0) (effects (font (size 1.27 1.27)))) + (pin "1" (uuid "p1")) (pin "2" (uuid "p2")) + ) + (global_label "GND" (at 0 3.81 0) (uuid "b")) +) +""") + pcb = tmp_path / "pcb.kicad_pcb" + pcb.write_text(_PCB_V10) + bom = tmp_path / "bom.csv" + bom.write_text( + "Reference,Value,Footprint,Manufacturer Part Number\n" + "U3,AP2112,SOT23,\nU5,ESP32,,\nR1,10k,,\n" + ) + g = build_graph( + sch, bom, tmp_path / "ex", tmp_path / "pat", tmp_path / "mod", + pcb_path=pcb, + ) + assert g.components["U3"].pins["2"] == "GND" + assert g.components["U3"].pins["5"] == "3V3_DIGITAL" + assert g.components["U5"].pins["1"] == "GND" + assert g.components["U5"].pins["2"] == "3V3_DIGITAL" + + def test_upload_pcb_sets_has_pcb(tmp_path: Path): from fastapi.testclient import TestClient from backend.main import app