""" 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, }