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:
@@ -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():
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user