Files
kicad-mcp-server/python/commands/schematic_snap.py
Eugene Mikhantyev ba09fc4e0f fix: change snap_to_grid default grid from 2.54mm to 1.27mm (50 mil)
Half of all valid KiCAD schematic pin positions are on the 50-mil
(1.27mm) grid but not the 100-mil (2.54mm) grid — e.g. 26.67mm = 21 ×
1.27mm. Snapping to 2.54mm displaced those coordinates by 1.27mm,
moving labels off their pins and increasing floating-label count.

KiCAD source confirms: DEFAULT_CONNECTION_GRID_MILS = 50 and the ERC
off-grid check uses exact integer modulo against this value, so any
displacement breaks connectivity unconditionally.

Also update the kicad-source absolute path in CLAUDE.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:10:55 +01:00

212 lines
7.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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 schematic grid is 50 mil (1.27 mm). Component pins are
placed at multiples of 1.27 mm relative to the symbol origin, so absolute pin
coordinates end up as odd multiples of 1.27 mm (e.g. 26.67 mm = 21 × 1.27 mm).
These are valid on-grid positions that must not be moved.
The coarser 2.54 mm (100-mil) grid is a common mistake: exactly half of all
valid 1.27 mm positions are not multiples of 2.54 mm and would be displaced by
1.27 mm — moving labels or wire endpoints off their pins and breaking
connectivity.
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 = 1.27
# 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 1.27 mm = 50 mil).
Do NOT use 2.54 mm — half of all valid KiCAD pin
positions fall between 2.54 mm grid lines and would
be displaced 1.27 mm, breaking connectivity.
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,
}