kicad-mcp/src/mckicad/patterns/_geometry.py
Ryan Malloy ce65035a17 Add batch operations, power symbols, pattern templates, and schematic editing
New modules:
- patterns/ library: decoupling bank, pull resistor, crystal oscillator
  placement with power symbol attachment and grid math helpers
- tools/batch.py: atomic file-based batch operations with dry_run
- tools/power_symbols.py: add_power_symbol with auto #PWR refs
- tools/schematic_patterns.py: MCP wrappers for pattern library
- tools/schematic_edit.py: modify/remove components, title blocks, annotations
- resources/schematic.py: schematic data resources

43 new tests (99 total), lint clean.
2026-03-04 16:55:09 -07:00

227 lines
6.8 KiB
Python

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