From 93876833688b42ce29e3dadd2663a0b3e2e3d747 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 12 Apr 2026 15:03:35 +0100 Subject: [PATCH] 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 --- python/commands/schematic_snap.py | 199 ++++++++++++++++++++++++++ python/kicad_interface.py | 33 +++++ python/schemas/tool_schemas.py | 46 ++++++ src/tools/schematic.ts | 32 +++++ tests/test_snap_to_grid.py | 229 ++++++++++++++++++++++++++++++ 5 files changed, 539 insertions(+) create mode 100644 python/commands/schematic_snap.py create mode 100644 tests/test_snap_to_grid.py diff --git a/python/commands/schematic_snap.py b/python/commands/schematic_snap.py new file mode 100644 index 0000000..fa3568a --- /dev/null +++ b/python/commands/schematic_snap.py @@ -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, + } diff --git a/python/kicad_interface.py b/python/kicad_interface.py index e77d2d3..5600828 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -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") diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index 3426228..dad7d47 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -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"], + }, + }, ] # ============================================================================= diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index d2ce2d5..d32e33f 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -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"}` }], + }; + }, + ); } diff --git a/tests/test_snap_to_grid.py b/tests/test_snap_to_grid.py new file mode 100644 index 0000000..4089801 --- /dev/null +++ b/tests/test_snap_to_grid.py @@ -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"