- Rename add_schematic_connection → add_schematic_wire with waypoints[] parameter - Add snapToPins (default true) to snap wire endpoints to nearest pin - Expose add_schematic_junction as an MCP tool - Break existing wires at new wire endpoints for T-junction support - Remove orphaned add_connection / add_wire / get_pin_location from ConnectionManager - Update tool registry to reflect renamed schematic tools in TS layer - Add 76 tests for wire/junction handler dispatch, schema validation, and WireManager corner cases - Apply Black and Prettier formatting to changed files Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
459 lines
18 KiB
Python
459 lines
18 KiB
Python
from skip import Schematic
|
|
import os
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Import new wire and pin managers
|
|
try:
|
|
from commands.wire_manager import WireManager
|
|
from commands.pin_locator import PinLocator
|
|
|
|
WIRE_MANAGER_AVAILABLE = True
|
|
except ImportError:
|
|
logger.warning("WireManager/PinLocator not available")
|
|
WIRE_MANAGER_AVAILABLE = False
|
|
|
|
|
|
class ConnectionManager:
|
|
"""Manage connections between components in schematics"""
|
|
|
|
# Initialize pin locator (class variable, shared across instances)
|
|
_pin_locator = None
|
|
|
|
@classmethod
|
|
def get_pin_locator(cls):
|
|
"""Get or create pin locator instance"""
|
|
if cls._pin_locator is None and WIRE_MANAGER_AVAILABLE:
|
|
cls._pin_locator = PinLocator()
|
|
return cls._pin_locator
|
|
|
|
@staticmethod
|
|
def add_net_label(schematic: Schematic, net_name: str, position: list):
|
|
"""
|
|
Add a net label to the schematic
|
|
|
|
Args:
|
|
schematic: Schematic object
|
|
net_name: Name of the net (e.g., "VCC", "GND", "SIGNAL_1")
|
|
position: [x, y] coordinates for the label
|
|
|
|
Returns:
|
|
Label object or None on error
|
|
"""
|
|
try:
|
|
if not hasattr(schematic, "label"):
|
|
logger.error("Schematic does not have label collection")
|
|
return None
|
|
|
|
label = schematic.label.append(
|
|
text=net_name, at={"x": position[0], "y": position[1]}
|
|
)
|
|
logger.info(f"Added net label '{net_name}' at {position}")
|
|
return label
|
|
except Exception as e:
|
|
logger.error(f"Error adding net label: {e}")
|
|
return None
|
|
|
|
@staticmethod
|
|
def connect_to_net(
|
|
schematic_path: Path, component_ref: str, pin_name: str, net_name: str
|
|
):
|
|
"""
|
|
Connect a component pin to a named net using a wire stub and label
|
|
|
|
Args:
|
|
schematic_path: Path to .kicad_sch file
|
|
component_ref: Reference designator (e.g., "U1", "U1_")
|
|
pin_name: Pin name/number
|
|
net_name: Name of the net to connect to (e.g., "VCC", "GND", "SIGNAL_1")
|
|
|
|
Returns:
|
|
True if successful, False otherwise
|
|
"""
|
|
try:
|
|
if not WIRE_MANAGER_AVAILABLE:
|
|
logger.error("WireManager/PinLocator not available")
|
|
return False
|
|
|
|
locator = ConnectionManager.get_pin_locator()
|
|
if not locator:
|
|
logger.error("Pin locator unavailable")
|
|
return False
|
|
|
|
# Get pin location using PinLocator
|
|
pin_loc = locator.get_pin_location(schematic_path, component_ref, pin_name)
|
|
if not pin_loc:
|
|
logger.error(f"Could not locate pin {component_ref}/{pin_name}")
|
|
return False
|
|
|
|
# Add a small wire stub from the pin (2.54mm = 0.1 inch, standard grid spacing)
|
|
# Stub direction follows the pin's outward angle from the PinLocator
|
|
pin_angle_deg = getattr(locator, "_last_pin_angle", 0)
|
|
try:
|
|
pin_angle_deg = (
|
|
locator.get_pin_angle(schematic_path, component_ref, pin_name) or 0
|
|
)
|
|
except Exception:
|
|
pin_angle_deg = 0
|
|
import math as _math
|
|
|
|
angle_rad = _math.radians(pin_angle_deg)
|
|
stub_end = [
|
|
round(pin_loc[0] + 2.54 * _math.cos(angle_rad), 4),
|
|
round(pin_loc[1] - 2.54 * _math.sin(angle_rad), 4),
|
|
]
|
|
|
|
# Create wire stub using WireManager
|
|
wire_success = WireManager.add_wire(schematic_path, pin_loc, stub_end)
|
|
if not wire_success:
|
|
logger.error(f"Failed to create wire stub for net connection")
|
|
return False
|
|
|
|
# Add label at the end of the stub using WireManager
|
|
label_success = WireManager.add_label(
|
|
schematic_path, net_name, stub_end, label_type="label"
|
|
)
|
|
if not label_success:
|
|
logger.error(f"Failed to add net label '{net_name}'")
|
|
return False
|
|
|
|
logger.info(f"Connected {component_ref}/{pin_name} to net '{net_name}'")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error connecting to net: {e}")
|
|
import traceback
|
|
|
|
logger.error(traceback.format_exc())
|
|
return False
|
|
|
|
@staticmethod
|
|
def connect_passthrough(
|
|
schematic_path: Path,
|
|
source_ref: str,
|
|
target_ref: str,
|
|
net_prefix: str = "PIN",
|
|
pin_offset: int = 0,
|
|
):
|
|
"""
|
|
Connect all pins of source_ref to matching pins of target_ref via shared net labels.
|
|
Useful for passthrough adapters: J1 pin N <-> J2 pin N on net {net_prefix}_{N}.
|
|
|
|
Args:
|
|
schematic_path: Path to .kicad_sch file
|
|
source_ref: Reference of the first connector (e.g., "J1")
|
|
target_ref: Reference of the second connector (e.g., "J2")
|
|
net_prefix: Prefix for generated net names (default: "PIN" -> PIN_1, PIN_2, ...)
|
|
pin_offset: Add this value to the pin number when building the net name (default 0)
|
|
|
|
Returns:
|
|
dict with 'connected' list and 'failed' list
|
|
"""
|
|
if not WIRE_MANAGER_AVAILABLE:
|
|
logger.error("WireManager/PinLocator not available")
|
|
return {"connected": [], "failed": ["WireManager unavailable"]}
|
|
|
|
locator = ConnectionManager.get_pin_locator()
|
|
if not locator:
|
|
return {"connected": [], "failed": ["PinLocator unavailable"]}
|
|
|
|
# Get all pins of source and target
|
|
src_pins = locator.get_all_symbol_pins(schematic_path, source_ref) or {}
|
|
tgt_pins = locator.get_all_symbol_pins(schematic_path, target_ref) or {}
|
|
|
|
if not src_pins:
|
|
return {"connected": [], "failed": [f"No pins found on {source_ref}"]}
|
|
if not tgt_pins:
|
|
return {"connected": [], "failed": [f"No pins found on {target_ref}"]}
|
|
|
|
connected = []
|
|
failed = []
|
|
|
|
for pin_num in sorted(
|
|
src_pins.keys(), key=lambda x: int(x) if x.isdigit() else 0
|
|
):
|
|
try:
|
|
net_name = (
|
|
f"{net_prefix}_{int(pin_num) + pin_offset}"
|
|
if pin_num.isdigit()
|
|
else f"{net_prefix}_{pin_num}"
|
|
)
|
|
|
|
ok_src = ConnectionManager.connect_to_net(
|
|
schematic_path, source_ref, pin_num, net_name
|
|
)
|
|
if not ok_src:
|
|
failed.append(f"{source_ref}/{pin_num}")
|
|
continue
|
|
|
|
if pin_num in tgt_pins:
|
|
ok_tgt = ConnectionManager.connect_to_net(
|
|
schematic_path, target_ref, pin_num, net_name
|
|
)
|
|
if not ok_tgt:
|
|
failed.append(f"{target_ref}/{pin_num}")
|
|
continue
|
|
else:
|
|
failed.append(f"{target_ref}/{pin_num} (pin not found)")
|
|
continue
|
|
|
|
connected.append(
|
|
f"{source_ref}/{pin_num} <-> {target_ref}/{pin_num} [{net_name}]"
|
|
)
|
|
except Exception as e:
|
|
failed.append(f"{source_ref}/{pin_num}: {e}")
|
|
|
|
logger.info(
|
|
f"connect_passthrough: {len(connected)} connected, {len(failed)} failed"
|
|
)
|
|
return {"connected": connected, "failed": failed}
|
|
|
|
@staticmethod
|
|
def get_net_connections(
|
|
schematic: Schematic, net_name: str, schematic_path: Optional[Path] = None
|
|
):
|
|
"""
|
|
Get all connections for a named net using wire graph analysis
|
|
|
|
Args:
|
|
schematic: Schematic object
|
|
net_name: Name of the net to query
|
|
schematic_path: Optional path to schematic file (enables accurate pin matching)
|
|
|
|
Returns:
|
|
List of connections: [{"component": ref, "pin": pin_name}, ...]
|
|
"""
|
|
try:
|
|
from commands.pin_locator import PinLocator
|
|
|
|
connections = []
|
|
tolerance = 0.5 # 0.5mm tolerance for point coincidence (grid spacing consideration)
|
|
|
|
def points_coincide(p1, p2):
|
|
"""Check if two points are the same (within tolerance)"""
|
|
if not p1 or not p2:
|
|
return False
|
|
dx = abs(p1[0] - p2[0])
|
|
dy = abs(p1[1] - p2[1])
|
|
return dx < tolerance and dy < tolerance
|
|
|
|
# 1. Find all labels with this net name
|
|
if not hasattr(schematic, "label"):
|
|
logger.warning("Schematic has no labels")
|
|
return connections
|
|
|
|
net_label_positions = []
|
|
for label in schematic.label:
|
|
if hasattr(label, "value") and label.value == net_name:
|
|
if hasattr(label, "at") and hasattr(label.at, "value"):
|
|
pos = label.at.value
|
|
net_label_positions.append([float(pos[0]), float(pos[1])])
|
|
|
|
if not net_label_positions:
|
|
logger.info(f"No labels found for net '{net_name}'")
|
|
return connections
|
|
|
|
logger.debug(
|
|
f"Found {len(net_label_positions)} labels for net '{net_name}'"
|
|
)
|
|
|
|
# 2. Find all wires connected to these label positions
|
|
if not hasattr(schematic, "wire"):
|
|
logger.warning("Schematic has no wires")
|
|
return connections
|
|
|
|
connected_wire_points = set()
|
|
for wire in schematic.wire:
|
|
if hasattr(wire, "pts") and hasattr(wire.pts, "xy"):
|
|
# Get all points in this wire (polyline)
|
|
wire_points = []
|
|
for point in wire.pts.xy:
|
|
if hasattr(point, "value"):
|
|
wire_points.append(
|
|
[float(point.value[0]), float(point.value[1])]
|
|
)
|
|
|
|
# Check if any wire point touches a label
|
|
wire_connected = False
|
|
for wire_pt in wire_points:
|
|
for label_pt in net_label_positions:
|
|
if points_coincide(wire_pt, label_pt):
|
|
wire_connected = True
|
|
break
|
|
if wire_connected:
|
|
break
|
|
|
|
# If this wire is connected to the net, add all its points
|
|
if wire_connected:
|
|
for pt in wire_points:
|
|
connected_wire_points.add((pt[0], pt[1]))
|
|
|
|
if not connected_wire_points:
|
|
logger.debug(f"No wires connected to net '{net_name}' labels")
|
|
return connections
|
|
|
|
logger.debug(
|
|
f"Found {len(connected_wire_points)} wire connection points for net '{net_name}'"
|
|
)
|
|
|
|
# 3. Find component pins at wire endpoints
|
|
if not hasattr(schematic, "symbol"):
|
|
logger.warning("Schematic has no symbols")
|
|
return connections
|
|
|
|
# Create pin locator for accurate pin matching (if schematic_path available)
|
|
locator = None
|
|
if schematic_path and WIRE_MANAGER_AVAILABLE:
|
|
locator = PinLocator()
|
|
|
|
for symbol in schematic.symbol:
|
|
# Skip template symbols
|
|
if not hasattr(symbol.property, "Reference"):
|
|
continue
|
|
|
|
ref = symbol.property.Reference.value
|
|
if ref.startswith("_TEMPLATE"):
|
|
continue
|
|
|
|
# Get lib_id for pin location lookup
|
|
lib_id = symbol.lib_id.value if hasattr(symbol, "lib_id") else None
|
|
if not lib_id:
|
|
continue
|
|
|
|
# If we have PinLocator and schematic_path, do accurate pin matching
|
|
if locator and schematic_path:
|
|
try:
|
|
# Get all pins for this symbol
|
|
pins = locator.get_symbol_pins(schematic_path, lib_id)
|
|
if not pins:
|
|
continue
|
|
|
|
# Check each pin
|
|
for pin_num, pin_data in pins.items():
|
|
# Get pin location
|
|
pin_loc = locator.get_pin_location(
|
|
schematic_path, ref, pin_num
|
|
)
|
|
if not pin_loc:
|
|
continue
|
|
|
|
# Check if pin coincides with any wire point
|
|
for wire_pt in connected_wire_points:
|
|
if points_coincide(pin_loc, list(wire_pt)):
|
|
connections.append(
|
|
{"component": ref, "pin": pin_num}
|
|
)
|
|
break # Pin found, no need to check more wire points
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Error matching pins for {ref}: {e}")
|
|
# Fall back to proximity matching
|
|
pass
|
|
|
|
# Fallback: proximity-based matching if no PinLocator
|
|
if not locator or not schematic_path:
|
|
symbol_pos = symbol.at.value if hasattr(symbol, "at") else None
|
|
if not symbol_pos:
|
|
continue
|
|
|
|
symbol_x = float(symbol_pos[0])
|
|
symbol_y = float(symbol_pos[1])
|
|
|
|
# Check if symbol is near any wire point (within 10mm)
|
|
for wire_pt in connected_wire_points:
|
|
dist = (
|
|
(symbol_x - wire_pt[0]) ** 2 + (symbol_y - wire_pt[1]) ** 2
|
|
) ** 0.5
|
|
if dist < 10.0: # 10mm proximity threshold
|
|
connections.append({"component": ref, "pin": "unknown"})
|
|
break # Only add once per component
|
|
|
|
logger.info(f"Found {len(connections)} connections for net '{net_name}'")
|
|
return connections
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting net connections: {e}")
|
|
import traceback
|
|
|
|
logger.error(traceback.format_exc())
|
|
return []
|
|
|
|
@staticmethod
|
|
def generate_netlist(schematic: Schematic, schematic_path: Optional[Path] = None):
|
|
"""
|
|
Generate a netlist from the schematic
|
|
|
|
Args:
|
|
schematic: Schematic object
|
|
schematic_path: Optional path to schematic file (enables accurate pin matching
|
|
via PinLocator; without it, only one connection per component is found)
|
|
|
|
Returns:
|
|
Dictionary with net information:
|
|
{
|
|
"nets": [
|
|
{
|
|
"name": "VCC",
|
|
"connections": [
|
|
{"component": "R1", "pin": "1"},
|
|
{"component": "C1", "pin": "1"}
|
|
]
|
|
},
|
|
...
|
|
],
|
|
"components": [
|
|
{"reference": "R1", "value": "10k", "footprint": "..."},
|
|
...
|
|
]
|
|
}
|
|
"""
|
|
try:
|
|
netlist = {"nets": [], "components": []}
|
|
|
|
# Gather all components
|
|
if hasattr(schematic, "symbol"):
|
|
for symbol in schematic.symbol:
|
|
component_info = {
|
|
"reference": symbol.property.Reference.value,
|
|
"value": (
|
|
symbol.property.Value.value
|
|
if hasattr(symbol.property, "Value")
|
|
else ""
|
|
),
|
|
"footprint": (
|
|
symbol.property.Footprint.value
|
|
if hasattr(symbol.property, "Footprint")
|
|
else ""
|
|
),
|
|
}
|
|
netlist["components"].append(component_info)
|
|
|
|
# Gather all nets from labels
|
|
if hasattr(schematic, "label"):
|
|
net_names = set()
|
|
for label in schematic.label:
|
|
if hasattr(label, "value"):
|
|
net_names.add(label.value)
|
|
|
|
# For each net, get connections
|
|
for net_name in net_names:
|
|
connections = ConnectionManager.get_net_connections(
|
|
schematic, net_name, schematic_path
|
|
)
|
|
if connections:
|
|
netlist["nets"].append(
|
|
{"name": net_name, "connections": connections}
|
|
)
|
|
|
|
logger.info(
|
|
f"Generated netlist with {len(netlist['nets'])} nets and {len(netlist['components'])} components"
|
|
)
|
|
return netlist
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error generating netlist: {e}")
|
|
return {"nets": [], "components": []}
|