""" Shared geometry helpers for KiCad schematic pattern placement. These utilities handle grid snapping, layout generation, power symbol detection, and the core power-symbol-to-pin attachment logic shared by all pattern modules and the add_power_symbol MCP tool. All functions that accept a ``sch`` parameter expect a kicad-sch-api SchematicDocument. None of them call ``sch.save()`` — the caller is responsible for the load/save lifecycle. """ import logging import re from typing import Any from mckicad.config import COMMON_LIBRARIES, POWER_SYMBOL_DEFAULTS logger = logging.getLogger(__name__) # Pre-compiled patterns for ground net detection _GROUND_PATTERNS = re.compile( r"^(GND[A-Z0-9_]*|VSS[A-Z0-9_]*|AGND|DGND|PGND|SGND|GND)$", re.IGNORECASE, ) def snap_to_grid(value: float, grid: float = 2.54) -> float: """Snap a coordinate value to the nearest KiCad grid point. KiCad uses a 2.54mm (100mil) standard grid with a 1.27mm (50mil) fine grid. Snapping prevents off-grid placement that causes DRC warnings and connection issues. Args: value: Coordinate value in mm. grid: Grid spacing in mm. Defaults to 2.54 (standard grid). Returns: The nearest grid-aligned value. """ return round(value / grid) * grid def grid_positions( origin_x: float, origin_y: float, count: int, cols: int = 6, h_spacing: float = 12.7, v_spacing: float = 15.0, ) -> list[tuple[float, float]]: """Generate a grid of positions for placing multiple components. Fills left-to-right, top-to-bottom. All coordinates are snapped to the standard 2.54mm grid. Args: origin_x: Top-left X coordinate of the grid. origin_y: Top-left Y coordinate of the grid. count: Number of positions to generate. cols: Maximum columns before wrapping to the next row. h_spacing: Horizontal spacing between columns in mm. v_spacing: Vertical spacing between rows in mm. Returns: List of (x, y) tuples, one per requested position. """ positions: list[tuple[float, float]] = [] for i in range(count): col = i % cols row = i // cols x = snap_to_grid(origin_x + col * h_spacing) y = snap_to_grid(origin_y + row * v_spacing) positions.append((x, y)) return positions def is_ground_net(net: str) -> bool: """Heuristic check whether a net name represents a ground rail. Matches common ground naming conventions used in KiCad schematics: GND, GNDA, GNDD, PGND, VSS, VSSA, GND_*, etc. Args: net: Net name string. Returns: True if the net name matches a ground pattern. """ return bool(_GROUND_PATTERNS.match(net.strip())) def resolve_power_lib_id(net: str) -> str: """Map a net name to a KiCad power library symbol ID. Uses the COMMON_LIBRARIES power section first, then falls back to ``power:{net}`` which works for most standard symbols in KiCad's built-in power library (+5V, +3V3, VCC, GND, etc.). Args: net: Net name (e.g. "GND", "+3V3", "VCC"). Returns: Library ID string like ``power:GND`` or ``power:+3V3``. """ # Check the known power symbols table power_libs = COMMON_LIBRARIES.get("power", {}) key = net.lower().strip() if key in power_libs: entry = power_libs[key] return f"{entry['library']}:{entry['symbol']}" # Fall back to power:{net} — KiCad's power library uses the net name # as the symbol name for most standard rails return f"power:{net}" def _next_pwr_reference(sch: Any) -> str: """Find the next available ``#PWR0N`` reference in a schematic. Scans existing components for ``#PWR`` references and returns the next sequential number. """ max_num = 0 for comp in sch.components: ref = getattr(comp, "reference", "") if ref.startswith("#PWR"): suffix = ref[4:] try: num = int(suffix) max_num = max(max_num, num) except ValueError: pass return f"#PWR{max_num + 1:02d}" def add_power_symbol_to_pin( sch: Any, pin_position: tuple[float, float], net: str, lib_id: str | None = None, stub_length: float | None = None, ) -> dict[str, Any]: """Attach a power symbol to a pin position with a wire stub. Core logic shared by the ``add_power_symbol`` MCP tool and all pattern modules. Places the symbol above (supply) or below (ground) the pin with a connecting wire stub. Does NOT call ``sch.save()`` — the caller manages persistence. Args: sch: A kicad-sch-api SchematicDocument instance. pin_position: (x, y) coordinate of the target pin. net: Power net name (e.g. "GND", "+3V3", "VCC"). lib_id: Override the auto-detected library symbol ID. stub_length: Wire stub length in mm. Defaults to config value. Returns: Dictionary describing what was placed: reference, lib_id, symbol_position, wire_id, etc. """ if stub_length is None: stub_length = POWER_SYMBOL_DEFAULTS["stub_length"] if lib_id is None: lib_id = resolve_power_lib_id(net) pin_x, pin_y = pin_position ground = is_ground_net(net) # Ground symbols go below the pin; supply symbols go above. # In KiCad's coordinate system, Y increases downward. symbol_y = snap_to_grid(pin_y + stub_length) if ground else snap_to_grid(pin_y - stub_length) symbol_x = snap_to_grid(pin_x) symbol_y = snap_to_grid(symbol_y) # Auto-assign a #PWR reference reference = _next_pwr_reference(sch) # Place the power symbol using add_with_pin_at so pin 1 lands # at the symbol position (power symbols connect at their origin) try: sch.components.add_with_pin_at( lib_id=lib_id, pin_number="1", pin_position=(symbol_x, symbol_y), reference=reference, value=net, ) except (TypeError, AttributeError): # Fall back to positional add if add_with_pin_at unavailable sch.components.add( lib_id=lib_id, reference=reference, value=net, position=(symbol_x, symbol_y), ) # Draw wire stub from pin to power symbol wire_id = sch.add_wire( start=(pin_x, pin_y), end=(symbol_x, symbol_y), ) logger.info( "Placed %s (%s) at (%.2f, %.2f) with stub from (%.2f, %.2f)", reference, lib_id, symbol_x, symbol_y, pin_x, pin_y, ) return { "reference": reference, "lib_id": lib_id, "net": net, "symbol_position": {"x": symbol_x, "y": symbol_y}, "pin_position": {"x": pin_x, "y": pin_y}, "wire_id": wire_id, "direction": "down" if ground else "up", }