fix(sync_schematic_to_board): add missing footprints, not just nets (#171)

The handler iterated `board.GetFootprints()` and assigned nets to existing
pads, but had no path to *add* footprints for schematic symbols whose
Reference was not yet on the board. New parts placed on the schematic
landed in the net list with no PCB representation — the rats nest had
nowhere to terminate, and place_component / route_pad_to_pad would fail
because the target footprint did not exist.

KiCad's "Update PCB from Schematic" (F8) implicitly adds the missing
footprints; bring the MCP's behaviour in line.

Implementation:
* `_extract_components_from_schematic` runs `kicad-cli sch export netlist
  --format kicadxml` (the same path `_handle_generate_netlist` already
  uses) and returns a flat `[{reference, value, footprint}]` list. Walks
  hierarchical sub-sheets transparently because kicad-cli does.
* `_add_missing_footprints_from_schematic` resolves each missing component
  against the project's fp-lib-table via `LibraryManager`, calls
  `pcbnew.FootprintLoad`, sets reference / value / FPID, and places the
  footprint at the board origin (the user / autoplacer can position it).
  Power and flag references (`#PWR…`, `#FLG…`) are excluded — they have
  no PCB footprint.
* The pad-net assignment loop now runs *after* the add path, so newly
  placed footprints get their nets assigned in the same call.
* Response payload gains `footprints_added` and `footprints_skipped`
  diagnostic lists. The textual `message` field reports both the new
  footprint count and the existing net / pad counts.

Adds tests/test_sync_schematic_to_board_footprints.py — 9 unit tests
covering the add path (missing ref, already-present ref, power refs,
empty footprint, unknown library) and the kicad-cli helper (XML parse,
missing kicad-cli, non-zero exit).
This commit is contained in:
Matthew Runo
2026-05-18 11:05:22 -07:00
committed by GitHub
parent e66c13361b
commit 679ccbf744
2 changed files with 489 additions and 3 deletions

View File

@@ -4181,6 +4181,15 @@ class KiCADInterface:
# Build hierarchical pad→net map (walks all sub-sheets)
pad_net_map, net_names = self._build_hierarchical_pad_net_map(schematic_path)
# Add missing footprints from the schematic to the board *before*
# we add nets and assign pads — F8 in KiCad does this implicitly
# ("Update PCB from Schematic"), but our previous implementation
# only mutated nets, leaving newly-added schematic symbols with no
# PCB footprint at all.
added_footprints, skipped_footprints = self._add_missing_footprints_from_schematic(
board, schematic_path
)
# Add all nets to board
netinfo = board.GetNetInfo()
nets_by_name = netinfo.NetsByName()
@@ -4195,7 +4204,7 @@ class KiCADInterface:
netinfo = board.GetNetInfo()
nets_by_name = netinfo.NetsByName()
# Assign nets to pads
# Assign nets to pads (now also covers any footprints we just added)
assigned_pads = 0
unmatched = []
for fp in board.GetFootprints():
@@ -4219,15 +4228,21 @@ class KiCADInterface:
self._update_command_handlers()
logger.info(
f"sync_schematic_to_board: {len(added_nets)} nets added, {assigned_pads} pads assigned"
f"sync_schematic_to_board: {len(added_nets)} nets added, "
f"{len(added_footprints)} footprints added, {assigned_pads} pads assigned"
)
return {
"success": True,
"message": f"PCB nets synced from schematic: {len(added_nets)} nets added, {assigned_pads} pads assigned",
"message": (
f"PCB updated from schematic: {len(added_footprints)} footprints added, "
f"{len(added_nets)} nets added, {assigned_pads} pads assigned"
),
"nets_added": added_nets,
"nets_total": len(net_names),
"pads_assigned": assigned_pads,
"unmatched_pads_sample": unmatched[:10],
"footprints_added": added_footprints,
"footprints_skipped": skipped_footprints,
}
except Exception as e:
@@ -4237,6 +4252,155 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _extract_components_from_schematic(self, schematic_path: str) -> List[Dict[str, str]]:
"""Run kicad-cli netlist export and return the flat list of components.
Each entry: {"reference": str, "value": str, "footprint": str}
Empty list on any failure (kicad-cli missing, parse error, etc.) — the
caller treats that as "no missing footprints to add".
"""
import subprocess
import tempfile
import xml.etree.ElementTree as ET
kicad_cli = self._find_kicad_cli_static()
if not kicad_cli:
logger.warning("kicad-cli not found — sync will not add new footprints")
return []
with tempfile.NamedTemporaryFile(suffix=".xml", delete=False) as tmp:
tmp_path = tmp.name
try:
cmd = [
kicad_cli,
"sch",
"export",
"netlist",
"--format",
"kicadxml",
"--output",
tmp_path,
schematic_path,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode != 0:
logger.warning(
f"kicad-cli netlist export failed (exit {result.returncode}): "
f"{result.stderr.strip()}"
)
return []
tree = ET.parse(tmp_path)
root = tree.getroot()
components = []
for comp in root.findall("./components/comp"):
components.append(
{
"reference": comp.get("ref", ""),
"value": comp.findtext("value", ""),
"footprint": comp.findtext("footprint", ""),
}
)
return components
except Exception as e:
logger.warning(f"Failed to extract components from schematic: {e}")
return []
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
def _add_missing_footprints_from_schematic(
self, board: Any, schematic_path: str
) -> Tuple[List[Dict[str, str]], List[Dict[str, str]]]:
"""Add footprints to ``board`` for any schematic component not yet present.
New footprints are placed at the board origin so the user can move them
into position. Power/flag references (``#PWR``, ``#FLG``) are skipped —
they have no PCB representation.
Returns ``(added, skipped)``: each entry is
``{"reference": str, "footprint": str, "reason": str?}``.
"""
from pathlib import Path
from commands.library import LibraryManager
added: List[Dict[str, str]] = []
skipped: List[Dict[str, str]] = []
components = self._extract_components_from_schematic(schematic_path)
if not components:
return added, skipped
existing_refs = {fp.GetReference() for fp in board.GetFootprints()}
project_dir = Path(schematic_path).parent
library_manager = LibraryManager(project_path=project_dir)
for comp in components:
ref = comp["reference"]
fp_str = comp["footprint"]
if not ref or ref.startswith("#"):
# Power flags / global indicators — no PCB footprint expected.
continue
if ref in existing_refs:
continue
if not fp_str or ":" not in fp_str:
skipped.append(
{
"reference": ref,
"footprint": fp_str,
"reason": "no Library:Name footprint set on schematic symbol",
}
)
continue
lib_name, fp_name = fp_str.split(":", 1)
library_path = library_manager.libraries.get(lib_name)
if not library_path:
skipped.append(
{
"reference": ref,
"footprint": fp_str,
"reason": f"library '{lib_name}' not in fp-lib-table",
}
)
continue
try:
module = pcbnew.FootprintLoad(library_path, fp_name)
except Exception as e:
skipped.append(
{"reference": ref, "footprint": fp_str, "reason": f"FootprintLoad failed: {e}"}
)
continue
if not module:
skipped.append(
{
"reference": ref,
"footprint": fp_str,
"reason": f"footprint '{fp_name}' not in '{lib_name}'",
}
)
continue
module.SetReference(ref)
if comp["value"]:
module.SetValue(comp["value"])
module.SetFPID(pcbnew.LIB_ID(lib_name, fp_name))
# Place at board origin; user / autoplacer can position from there.
module.SetPosition(pcbnew.VECTOR2I(0, 0))
board.Add(module)
existing_refs.add(ref)
added.append({"reference": ref, "footprint": fp_str})
if added:
logger.info(f"_add_missing_footprints_from_schematic: added {len(added)} footprints")
return added, skipped
# ===================================================================
# Schematic analysis tools (read-only)
# ===================================================================

View File

@@ -0,0 +1,322 @@
"""
Regression tests for sync_schematic_to_board's footprint-add path.
Before the fix, _handle_sync_schematic_to_board only mutated nets and pad
assignments — it iterated board.GetFootprints() and never added new ones.
A schematic symbol whose Reference was not yet on the PCB was therefore
silently dropped on the floor: no footprint added, no rats nest reaching
the missing component.
These tests cover _add_missing_footprints_from_schematic and its kicad-cli
helper _extract_components_from_schematic.
"""
import sys
from pathlib import Path
from typing import Any, List
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_existing_fp(reference: str) -> MagicMock:
fp = MagicMock(name=f"existing_fp_{reference}")
fp.GetReference.return_value = reference
return fp
def _interface() -> Any:
from kicad_interface import KiCADInterface
return KiCADInterface()
# ---------------------------------------------------------------------------
# _add_missing_footprints_from_schematic
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestAddMissingFootprintsFromSchematic:
"""The fix path: walk netlist, add footprints for refs not yet on the board."""
def _patch_extract(self, components: List[dict]) -> Any:
return patch.object(
_interface().__class__,
"_extract_components_from_schematic",
return_value=components,
)
def test_adds_footprint_for_missing_reference(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch"
sch.write_text("(kicad_sch)\n")
board = MagicMock(name="board")
board.GetFootprints.return_value = [] # nothing on the board yet
loaded_module = MagicMock(name="loaded_R0603")
with (
patch.object(
_interface().__class__,
"_extract_components_from_schematic",
return_value=[
{
"reference": "R99",
"value": "10k",
"footprint": "Resistor_SMD:R_0603_1608Metric",
}
],
),
patch("kicad_interface.pcbnew") as mock_pcbnew,
patch("commands.library.LibraryManager") as mock_lm_cls,
):
mock_pcbnew.FootprintLoad.return_value = loaded_module
lm = MagicMock()
lm.libraries = {"Resistor_SMD": "/fake/Resistor_SMD.pretty"}
mock_lm_cls.return_value = lm
iface = _interface()
added, skipped = iface._add_missing_footprints_from_schematic(board, str(sch))
assert len(added) == 1
assert added[0]["reference"] == "R99"
assert added[0]["footprint"] == "Resistor_SMD:R_0603_1608Metric"
assert skipped == []
# Footprint was added to the board.
board.Add.assert_called_once_with(loaded_module)
loaded_module.SetReference.assert_called_with("R99")
loaded_module.SetValue.assert_called_with("10k")
def test_skips_reference_already_on_board(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch"
sch.write_text("(kicad_sch)\n")
board = MagicMock(name="board")
board.GetFootprints.return_value = [_make_existing_fp("R1")]
with (
patch.object(
_interface().__class__,
"_extract_components_from_schematic",
return_value=[
{
"reference": "R1",
"value": "10k",
"footprint": "Resistor_SMD:R_0603_1608Metric",
}
],
),
patch("kicad_interface.pcbnew"),
patch("commands.library.LibraryManager") as mock_lm_cls,
):
lm = MagicMock()
lm.libraries = {"Resistor_SMD": "/fake/Resistor_SMD.pretty"}
mock_lm_cls.return_value = lm
iface = _interface()
added, skipped = iface._add_missing_footprints_from_schematic(board, str(sch))
assert added == []
assert skipped == []
board.Add.assert_not_called()
def test_skips_power_symbols(self, tmp_path: Any) -> None:
"""References starting with # (e.g. #PWR, #FLG) have no PCB footprint."""
sch = tmp_path / "test.kicad_sch"
sch.write_text("(kicad_sch)\n")
board = MagicMock(name="board")
board.GetFootprints.return_value = []
with (
patch.object(
_interface().__class__,
"_extract_components_from_schematic",
return_value=[
{"reference": "#PWR0001", "value": "GND", "footprint": ""},
{"reference": "#FLG0001", "value": "PWR_FLAG", "footprint": ""},
],
),
patch("kicad_interface.pcbnew"),
patch("commands.library.LibraryManager") as mock_lm_cls,
):
mock_lm_cls.return_value = MagicMock(libraries={})
iface = _interface()
added, skipped = iface._add_missing_footprints_from_schematic(board, str(sch))
assert added == []
# Power refs are excluded entirely — they don't show up in the skipped
# diagnostic list either, since "no PCB footprint" is the right answer.
assert skipped == []
board.Add.assert_not_called()
def test_records_skip_reason_for_missing_footprint_property(self, tmp_path: Any) -> None:
"""A schematic symbol with no Footprint property is reported as skipped."""
sch = tmp_path / "test.kicad_sch"
sch.write_text("(kicad_sch)\n")
board = MagicMock(name="board")
board.GetFootprints.return_value = []
with (
patch.object(
_interface().__class__,
"_extract_components_from_schematic",
return_value=[{"reference": "R1", "value": "10k", "footprint": ""}],
),
patch("kicad_interface.pcbnew"),
patch("commands.library.LibraryManager") as mock_lm_cls,
):
mock_lm_cls.return_value = MagicMock(libraries={})
iface = _interface()
added, skipped = iface._add_missing_footprints_from_schematic(board, str(sch))
assert added == []
assert len(skipped) == 1
assert skipped[0]["reference"] == "R1"
assert "no Library:Name" in skipped[0]["reason"]
def test_records_skip_reason_for_unknown_library(self, tmp_path: Any) -> None:
"""If the footprint's library nickname isn't in fp-lib-table, skip with reason."""
sch = tmp_path / "test.kicad_sch"
sch.write_text("(kicad_sch)\n")
board = MagicMock(name="board")
board.GetFootprints.return_value = []
with (
patch.object(
_interface().__class__,
"_extract_components_from_schematic",
return_value=[
{
"reference": "U1",
"value": "MyChip",
"footprint": "MyVendor:MyChip_QFN24",
}
],
),
patch("kicad_interface.pcbnew"),
patch("commands.library.LibraryManager") as mock_lm_cls,
):
mock_lm_cls.return_value = MagicMock(libraries={}) # MyVendor not present
iface = _interface()
added, skipped = iface._add_missing_footprints_from_schematic(board, str(sch))
assert added == []
assert len(skipped) == 1
assert skipped[0]["reference"] == "U1"
assert "MyVendor" in skipped[0]["reason"]
def test_no_op_when_kicad_cli_returns_empty(self, tmp_path: Any) -> None:
"""If the netlist extractor returns nothing, the helper is a no-op."""
sch = tmp_path / "test.kicad_sch"
sch.write_text("(kicad_sch)\n")
board = MagicMock(name="board")
board.GetFootprints.return_value = []
with patch.object(
_interface().__class__,
"_extract_components_from_schematic",
return_value=[],
):
iface = _interface()
added, skipped = iface._add_missing_footprints_from_schematic(board, str(sch))
assert added == []
assert skipped == []
board.Add.assert_not_called()
# ---------------------------------------------------------------------------
# _extract_components_from_schematic
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestExtractComponentsFromSchematic:
"""The kicad-cli helper that produces (reference, value, footprint) records."""
def test_parses_kicad_xml_netlist(self, tmp_path: Any) -> None:
netlist_xml = """<?xml version="1.0" encoding="UTF-8"?>
<export version="E">
<design />
<components>
<comp ref="R1">
<value>10k</value>
<footprint>Resistor_SMD:R_0603_1608Metric</footprint>
</comp>
<comp ref="C1">
<value>0.1uF</value>
<footprint>Capacitor_SMD:C_0603_1608Metric</footprint>
</comp>
<comp ref="U1">
<value>MyChip</value>
<footprint />
</comp>
</components>
<nets />
</export>
"""
sch = tmp_path / "test.kicad_sch"
sch.write_text("(kicad_sch)\n")
def fake_run(cmd: Any, **kwargs: Any) -> Any:
output_idx = cmd.index("--output") + 1
Path(cmd[output_idx]).write_text(netlist_xml)
return MagicMock(returncode=0, stderr="", stdout="")
with (
patch.object(
_interface().__class__, "_find_kicad_cli_static", return_value="/fake/kicad-cli"
),
patch("subprocess.run", side_effect=fake_run),
):
iface = _interface()
comps = iface._extract_components_from_schematic(str(sch))
assert len(comps) == 3
refs = [c["reference"] for c in comps]
assert refs == ["R1", "C1", "U1"]
# Empty <footprint /> resolves to ""
u1 = next(c for c in comps if c["reference"] == "U1")
assert u1["footprint"] == ""
def test_returns_empty_when_kicad_cli_missing(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch"
sch.write_text("(kicad_sch)\n")
with patch.object(_interface().__class__, "_find_kicad_cli_static", return_value=None):
iface = _interface()
comps = iface._extract_components_from_schematic(str(sch))
assert comps == []
def test_returns_empty_when_kicad_cli_fails(self, tmp_path: Any) -> None:
sch = tmp_path / "test.kicad_sch"
sch.write_text("(kicad_sch)\n")
with (
patch.object(
_interface().__class__, "_find_kicad_cli_static", return_value="/fake/kicad-cli"
),
patch(
"subprocess.run",
return_value=MagicMock(returncode=1, stderr="boom", stdout=""),
),
):
iface = _interface()
comps = iface._extract_components_from_schematic(str(sch))
assert comps == []