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.
227 lines
6.8 KiB
Python
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",
|
|
}
|