Prefer .kicad_pcb pad nets when building the design graph.

Schematic geometry on KiCad 10 boards was swapping rails and inventing
floating pins; board connectivity is authoritative when a PCB is uploaded.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-11 12:39:49 +02:00
co-authored by Cursor
parent a1a2b2a944
commit c5b260b404
5 changed files with 150 additions and 1 deletions
+15
View File
@@ -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():
+47 -1
View File
@@ -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:
+2
View File
@@ -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")
+8
View File
@@ -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.
+78
View File
@@ -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