feat: add snap_to_grid schematic tool

Adds a new MCP tool that snaps wire endpoints, junction positions, and
net label coordinates to the nearest grid point (default 2.54 mm). Off-grid
coordinates cause wires that appear visually connected to fail ERC checks
because KiCAD uses exact IU integer matching internally; this tool eliminates
that class of error before running ERC.

- python/commands/schematic_snap.py: core snap logic with in-place sexp mutation
- python/kicad_interface.py: route + handler
- python/schemas/tool_schemas.py: JSON schema (gridSize, elements params)
- src/tools/schematic.ts: TypeScript MCP tool registration
- tests/test_snap_to_grid.py: 20 unit + integration tests (all passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Eugene Mikhantyev
2026-04-12 15:03:35 +01:00
parent 58bb08a252
commit 9387683368
5 changed files with 539 additions and 0 deletions

View File

@@ -0,0 +1,199 @@
"""
Snap-to-grid tool for KiCAD schematics.
Snaps wire endpoints, junction positions, net labels, and optionally component
positions to the nearest grid point. Modifies the schematic file in place.
The standard KiCAD eeschema grid is 2.54 mm (0.1 inch). Off-grid coordinates
cause wires that appear visually connected to fail ERC connectivity checks
because KiCAD uses exact integer (IU) matching internally.
"""
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
import sexpdata
from sexpdata import Symbol
logger = logging.getLogger("kicad_interface")
_DEFAULT_GRID_MM: float = 2.54
# Element type names exposed in the public API
_VALID_ELEMENTS = frozenset({"wires", "junctions", "labels", "components"})
# Tags treated as net labels (all have (at x y angle) structure)
_LABEL_TAGS = frozenset(
{
Symbol("label"),
Symbol("global_label"),
Symbol("hierarchical_label"),
Symbol("net_tie"),
Symbol("no_connect"),
}
)
def _snap_mm(value: float, grid_mm: float) -> float:
"""Snap a single coordinate to the nearest grid multiple."""
return round(value / grid_mm) * grid_mm
def _is_on_grid(value: float, grid_mm: float, eps: float = 1e-9) -> bool:
"""Return True if *value* is already within *eps* of a grid point."""
snapped = _snap_mm(value, grid_mm)
return abs(value - snapped) < eps
def _snap_xy_pair(item: list, grid_mm: float) -> int:
"""
Snap a ``(xy x y)`` S-expression item in place.
Returns 1 if at least one coordinate changed, 0 otherwise.
"""
if not (isinstance(item, list) and len(item) >= 3 and item[0] == Symbol("xy")):
return 0
x_orig, y_orig = float(item[1]), float(item[2])
x_new = _snap_mm(x_orig, grid_mm)
y_new = _snap_mm(y_orig, grid_mm)
changed = not (_is_on_grid(x_orig, grid_mm) and _is_on_grid(y_orig, grid_mm))
item[1] = x_new
item[2] = y_new
return 1 if changed else 0
def _snap_at_xy(item: list, grid_mm: float) -> int:
"""
Snap an ``(at x y ...)`` S-expression item in place (indices 1 and 2 only).
Preserves rotation / angle at index 3+ unchanged.
Returns 1 if at least one coordinate changed, 0 otherwise.
"""
if not (isinstance(item, list) and len(item) >= 3 and item[0] == Symbol("at")):
return 0
x_orig, y_orig = float(item[1]), float(item[2])
x_new = _snap_mm(x_orig, grid_mm)
y_new = _snap_mm(y_orig, grid_mm)
changed = not (_is_on_grid(x_orig, grid_mm) and _is_on_grid(y_orig, grid_mm))
item[1] = x_new
item[2] = y_new
return 1 if changed else 0
def snap_to_grid(
schematic_path: Path,
grid_size: float = _DEFAULT_GRID_MM,
elements: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""
Snap element coordinates in a ``.kicad_sch`` file to the nearest grid point.
Modifies the file in place and returns statistics.
Args:
schematic_path: Path to the ``.kicad_sch`` file.
grid_size: Grid spacing in mm (default 2.54 mm = 0.1 inch).
elements: List of element types to snap. Valid values:
``"wires"``, ``"junctions"``, ``"labels"``,
``"components"``. Defaults to
``["wires", "junctions", "labels"]`` when ``None``.
Returns:
``{"snapped": int, "already_on_grid": int, "grid_size": float}``
where *snapped* is the number of elements that had at least one
coordinate moved.
"""
if grid_size <= 0:
raise ValueError(f"grid_size must be positive, got {grid_size}")
if elements is None:
active: frozenset = frozenset({"wires", "junctions", "labels"})
else:
unknown = set(elements) - _VALID_ELEMENTS
if unknown:
raise ValueError(
f"Unknown element type(s): {sorted(unknown)}. "
f"Valid types: {sorted(_VALID_ELEMENTS)}"
)
active = frozenset(elements)
with open(schematic_path, "r", encoding="utf-8") as fh:
sch_data = sexpdata.loads(fh.read())
snapped = 0
already_on_grid = 0
snap_wires = "wires" in active
snap_junctions = "junctions" in active
snap_labels = "labels" in active
snap_components = "components" in active
for item in sch_data:
if not isinstance(item, list) or not item:
continue
tag = item[0]
# -----------------------------------------------------------------
# Wires: (wire (pts (xy x y) (xy x y)) ...)
# -----------------------------------------------------------------
if snap_wires and tag == Symbol("wire"):
changed = 0
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == Symbol("pts"):
for pt in sub[1:]:
changed += _snap_xy_pair(pt, grid_size)
if changed:
snapped += 1
else:
already_on_grid += 1
continue
# -----------------------------------------------------------------
# Junctions: (junction (at x y) ...)
# -----------------------------------------------------------------
if snap_junctions and tag == Symbol("junction"):
changed = 0
for sub in item[1:]:
changed += _snap_at_xy(sub, grid_size)
if changed:
snapped += 1
else:
already_on_grid += 1
continue
# -----------------------------------------------------------------
# Labels: (label|global_label|hierarchical_label|no_connect … (at x y angle) …)
# -----------------------------------------------------------------
if snap_labels and tag in _LABEL_TAGS:
changed = 0
for sub in item[1:]:
changed += _snap_at_xy(sub, grid_size)
if changed:
snapped += 1
else:
already_on_grid += 1
continue
# -----------------------------------------------------------------
# Components: (symbol (lib_id …) (at x y rotation) …)
# Snap only the top-level (at …) — not property sub-positions.
# -----------------------------------------------------------------
if snap_components and tag == Symbol("symbol"):
changed = 0
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == Symbol("at"):
changed += _snap_at_xy(sub, grid_size)
break # only the first (at …) belongs to the symbol itself
if changed:
snapped += 1
else:
already_on_grid += 1
continue
with open(schematic_path, "w", encoding="utf-8") as fh:
fh.write(sexpdata.dumps(sch_data))
return {
"snapped": snapped,
"already_on_grid": already_on_grid,
"grid_size": grid_size,
}

View File

@@ -404,6 +404,7 @@ class KiCADInterface:
"get_elements_in_region": self._handle_get_elements_in_region,
"find_wires_crossing_symbols": self._handle_find_wires_crossing_symbols,
"find_orphaned_wires": self._handle_find_orphaned_wires,
"snap_to_grid": self._handle_snap_to_grid,
"import_svg_logo": self._handle_import_svg_logo,
# UI/Process management commands
"check_kicad_ui": self._handle_check_kicad_ui,
@@ -2962,6 +2963,38 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_snap_to_grid(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Snap schematic element coordinates to the nearest grid point"""
logger.info("Snapping schematic elements to grid")
try:
from pathlib import Path
from commands.schematic_snap import snap_to_grid
schematic_path = params.get("schematicPath")
if not schematic_path:
return {"success": False, "message": "schematicPath is required"}
grid_size = float(params.get("gridSize", 2.54))
elements = params.get("elements") # None → defaults inside snap_to_grid
result = snap_to_grid(Path(schematic_path), grid_size=grid_size, elements=elements)
total = result["snapped"] + result["already_on_grid"]
return {
"success": True,
**result,
"message": (
f"Snapped {result['snapped']} element(s) to {grid_size} mm grid "
f"({result['already_on_grid']} of {total} were already on grid)"
),
}
except Exception as e:
logger.error(f"Error snapping to grid: {e}")
import traceback
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_import_svg_logo(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Import an SVG file as PCB graphic polygons on the silkscreen"""
logger.info("Importing SVG logo into PCB")

View File

@@ -1814,6 +1814,52 @@ SCHEMATIC_TOOLS = [
"required": ["schematicPath"],
},
},
{
"name": "snap_to_grid",
"title": "Snap Schematic Elements to Grid",
"description": (
"Snap schematic element coordinates to the nearest grid point. "
"KiCAD eeschema uses exact integer matching (10 000 IU/mm) for connectivity, "
"so even a sub-pixel coordinate offset will make wires appear connected visually "
"but fail ERC checks. Running this tool before ERC eliminates that class of error. "
"Modifies the .kicad_sch file in place. "
"Does not require the KiCAD UI to be running."
),
"inputSchema": {
"type": "object",
"properties": {
"schematicPath": {
"type": "string",
"description": "Path to the .kicad_sch schematic file",
},
"gridSize": {
"type": "number",
"description": (
"Grid spacing in mm. "
"Standard KiCAD schematic grid is 2.54 mm (0.1 inch). "
"Use 1.27 mm for high-density layouts. "
"Defaults to 2.54."
),
"default": 2.54,
},
"elements": {
"type": "array",
"description": (
"Element types to snap. "
'Valid values: "wires", "junctions", "labels", "components". '
'Defaults to ["wires", "junctions", "labels"] when omitted. '
'"components" is opt-in because moving a component without re-routing '
"its wires will create new mismatches."
),
"items": {
"type": "string",
"enum": ["wires", "junctions", "labels", "components"],
},
},
},
"required": ["schematicPath"],
},
},
]
# =============================================================================

View File

@@ -1392,4 +1392,36 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
};
},
);
// Snap schematic elements to grid
server.tool(
"snap_to_grid",
"Snap schematic element coordinates to the nearest grid point. " +
"KiCAD uses exact integer matching for connectivity, so off-grid coordinates cause wires " +
"that look connected to fail ERC checks. " +
"Modifies the .kicad_sch file in place. Does not require the KiCAD UI to be running.",
{
schematicPath: z.string().describe("Path to the .kicad_sch schematic file"),
gridSize: z
.number()
.optional()
.describe("Grid spacing in mm (default: 2.54 — standard KiCAD schematic grid)"),
elements: z
.array(z.enum(["wires", "junctions", "labels", "components"]))
.optional()
.describe(
'Element types to snap (default: ["wires", "junctions", "labels"]). ' +
'"components" is opt-in — moving a component without re-routing wires creates new mismatches.',
),
},
async (args: { schematicPath: string; gridSize?: number; elements?: string[] }) => {
const result = await callKicadScript("snap_to_grid", args);
if (result.success) {
return { content: [{ type: "text", text: result.message }] };
}
return {
content: [{ type: "text", text: `Failed: ${result.message || "Unknown error"}` }],
};
},
);
}

229
tests/test_snap_to_grid.py Normal file
View File

@@ -0,0 +1,229 @@
"""
Tests for the snap_to_grid schematic tool.
Unit tests cover the snapping math and per-element-type logic using synthetic
S-expressions. Integration tests run against real .kicad_sch files created
from the empty template.
"""
import shutil
import sys
import tempfile
import uuid
from pathlib import Path
import pytest
import sexpdata
from sexpdata import Symbol
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))
from commands.schematic_snap import _is_on_grid, _snap_mm, snap_to_grid
# ---------------------------------------------------------------------------
# Shared fixture helpers
# ---------------------------------------------------------------------------
TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "python" / "templates" / "empty.kicad_sch"
def _make_temp_schematic(extra_sexp: str = "") -> Path:
"""Copy empty.kicad_sch to a temp dir, optionally injecting extra S-expressions."""
tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch"
shutil.copy(TEMPLATE_PATH, tmp)
if extra_sexp:
content = tmp.read_text(encoding="utf-8")
idx = content.rfind(")")
content = content[:idx] + "\n" + extra_sexp + "\n)"
tmp.write_text(content, encoding="utf-8")
return tmp
def _wire_sexp(x1: float, y1: float, x2: float, y2: float) -> str:
u = str(uuid.uuid4())
return (
f"(wire (pts (xy {x1} {y1}) (xy {x2} {y2}))\n"
f" (stroke (width 0) (type default))\n"
f' (uuid "{u}"))'
)
def _junction_sexp(x: float, y: float) -> str:
u = str(uuid.uuid4())
return f'(junction (at {x} {y}) (diameter 0) (color 0 0 0 0) (uuid "{u}"))'
def _label_sexp(name: str, x: float, y: float, angle: float = 0) -> str:
u = str(uuid.uuid4())
return (
f'(label "{name}" (at {x} {y} {angle})\n'
f" (effects (font (size 1.27 1.27)) (justify left bottom))\n"
f' (uuid "{u}"))'
)
# ---------------------------------------------------------------------------
# Unit tests — pure math, no file I/O
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestSnapMath:
def test_snap_mm_already_on_grid(self):
assert _snap_mm(2.54, 2.54) == pytest.approx(2.54)
def test_snap_mm_rounds_up(self):
# 2.55 is closer to 5.08 than to 2.54 (distance 2.53 vs 0.01)
# Actually 2.55 / 2.54 = 1.0039..., rounds to 1 → 2.54
assert _snap_mm(2.55, 2.54) == pytest.approx(2.54)
def test_snap_mm_rounds_to_next(self):
# 3.81 / 2.54 = 1.5 → rounds to 2 → 5.08
assert _snap_mm(3.81, 2.54) == pytest.approx(5.08)
def test_snap_mm_negative(self):
assert _snap_mm(-2.51, 2.54) == pytest.approx(-2.54)
def test_snap_mm_zero(self):
assert _snap_mm(0.0, 2.54) == pytest.approx(0.0)
def test_snap_mm_small_grid(self):
assert _snap_mm(1.28, 1.27) == pytest.approx(1.27)
def test_is_on_grid_true(self):
assert _is_on_grid(2.54, 2.54)
assert _is_on_grid(0.0, 2.54)
assert _is_on_grid(5.08, 2.54)
def test_is_on_grid_false(self):
assert not _is_on_grid(2.55, 2.54)
assert not _is_on_grid(1.0, 2.54)
def test_snap_invalid_grid_raises(self):
with pytest.raises(ValueError, match="grid_size must be positive"):
snap_to_grid(Path("/nonexistent"), grid_size=-1.0)
def test_snap_unknown_element_raises(self):
with pytest.raises(ValueError, match="Unknown element type"):
snap_to_grid(Path("/nonexistent"), elements=["bogus"])
# ---------------------------------------------------------------------------
# Integration tests — real .kicad_sch files
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestSnapWires:
def test_off_grid_wire_is_snapped(self):
path = _make_temp_schematic(_wire_sexp(2.51, 5.03, 7.56, 5.03))
result = snap_to_grid(path, grid_size=2.54, elements=["wires"])
assert result["snapped"] >= 1
# Verify coordinates in the written file
data = sexpdata.loads(path.read_text(encoding="utf-8"))
wire = next(
item for item in data if isinstance(item, list) and item and item[0] == Symbol("wire")
)
pts = next(sub for sub in wire[1:] if isinstance(sub, list) and sub[0] == Symbol("pts"))
xy_pairs = [sub for sub in pts[1:] if isinstance(sub, list) and sub[0] == Symbol("xy")]
for pt in xy_pairs:
assert _is_on_grid(float(pt[1]), 2.54), f"x={pt[1]} not on grid"
assert _is_on_grid(float(pt[2]), 2.54), f"y={pt[2]} not on grid"
def test_on_grid_wire_counts_as_already_on_grid(self):
path = _make_temp_schematic(_wire_sexp(2.54, 5.08, 7.62, 5.08))
result = snap_to_grid(path, grid_size=2.54, elements=["wires"])
assert result["snapped"] == 0
assert result["already_on_grid"] >= 1
def test_wires_not_snapped_when_excluded(self):
path = _make_temp_schematic(_wire_sexp(2.51, 5.03, 7.56, 5.03))
result = snap_to_grid(path, grid_size=2.54, elements=["junctions"])
assert result["snapped"] == 0
@pytest.mark.integration
class TestSnapJunctions:
def test_off_grid_junction_is_snapped(self):
path = _make_temp_schematic(_junction_sexp(2.51, 2.51))
result = snap_to_grid(path, grid_size=2.54, elements=["junctions"])
assert result["snapped"] >= 1
data = sexpdata.loads(path.read_text(encoding="utf-8"))
junc = next(
item
for item in data
if isinstance(item, list) and item and item[0] == Symbol("junction")
)
at = next(sub for sub in junc[1:] if isinstance(sub, list) and sub[0] == Symbol("at"))
assert _is_on_grid(float(at[1]), 2.54)
assert _is_on_grid(float(at[2]), 2.54)
def test_on_grid_junction_unchanged(self):
path = _make_temp_schematic(_junction_sexp(2.54, 2.54))
result = snap_to_grid(path, grid_size=2.54, elements=["junctions"])
assert result["snapped"] == 0
assert result["already_on_grid"] >= 1
@pytest.mark.integration
class TestSnapLabels:
def test_off_grid_label_snapped_preserves_angle(self):
path = _make_temp_schematic(_label_sexp("NET_A", 2.51, 5.03, angle=90))
result = snap_to_grid(path, grid_size=2.54, elements=["labels"])
assert result["snapped"] >= 1
data = sexpdata.loads(path.read_text(encoding="utf-8"))
lbl = next(
item for item in data if isinstance(item, list) and item and item[0] == Symbol("label")
)
at = next(sub for sub in lbl[1:] if isinstance(sub, list) and sub[0] == Symbol("at"))
assert _is_on_grid(float(at[1]), 2.54), f"x={at[1]} not on grid"
assert _is_on_grid(float(at[2]), 2.54), f"y={at[2]} not on grid"
# angle must be preserved
assert float(at[3]) == pytest.approx(90.0)
def test_on_grid_label_unchanged(self):
path = _make_temp_schematic(_label_sexp("NET_B", 2.54, 5.08))
result = snap_to_grid(path, grid_size=2.54, elements=["labels"])
assert result["snapped"] == 0
@pytest.mark.integration
class TestSnapDefaults:
def test_default_elements_snaps_wires_and_junctions_and_labels(self):
extra = "\n".join(
[
_wire_sexp(2.51, 5.03, 7.56, 5.03),
_junction_sexp(2.51, 2.51),
_label_sexp("VCC", 2.51, 2.51),
]
)
path = _make_temp_schematic(extra)
result = snap_to_grid(path) # defaults: grid=2.54, elements=None
assert result["snapped"] >= 3
assert result["grid_size"] == pytest.approx(2.54)
def test_idempotent(self):
path = _make_temp_schematic(_wire_sexp(2.51, 5.03, 7.56, 5.03))
snap_to_grid(path, grid_size=2.54)
content_after_first = path.read_text(encoding="utf-8")
snap_to_grid(path, grid_size=2.54)
content_after_second = path.read_text(encoding="utf-8")
assert content_after_first == content_after_second
def test_custom_grid(self):
# 1.27 mm grid — wire at 1.25 should snap to 1.27
path = _make_temp_schematic(_wire_sexp(1.25, 1.25, 2.51, 2.51))
result = snap_to_grid(path, grid_size=1.27)
assert result["snapped"] >= 1
data = sexpdata.loads(path.read_text(encoding="utf-8"))
wire = next(
item for item in data if isinstance(item, list) and item and item[0] == Symbol("wire")
)
pts = next(sub for sub in wire[1:] if isinstance(sub, list) and sub[0] == Symbol("pts"))
xy_pairs = [sub for sub in pts[1:] if isinstance(sub, list) and sub[0] == Symbol("xy")]
for pt in xy_pairs:
assert _is_on_grid(float(pt[1]), 1.27), f"x={pt[1]} not on 1.27 grid"
assert _is_on_grid(float(pt[2]), 1.27), f"y={pt[2]} not on 1.27 grid"