fix: add pin-snapping and coordinate feedback to net label tools

add_schematic_net_label now accepts optional componentRef + pinNumber to
snap the label directly to the exact pin endpoint via PinLocator, removing
all approximation risk.  The response always includes actual_position and,
when snapping was used, snapped_to_pin — so the caller gets confirmation
of exactly where the label landed.

connect_to_net return type changed from bool to Dict, returning
pin_location, label_location, and wire_stub on success so agents no
longer need a separate verification call to confirm placement.

connect_passthrough updated to check result.get("success") against the
new dict return.  tool_schemas.py and schematic.ts updated to match
(position is now optional, componentRef/pinNumber/labelType/orientation
added, connect_to_net schema field names corrected).

17 new unit tests in tests/test_net_label_pin_snapping.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Eugene Mikhantyev
2026-04-12 14:12:11 +01:00
parent 0bf73674da
commit 94125eda7f
5 changed files with 481 additions and 55 deletions

View File

@@ -59,9 +59,9 @@ class ConnectionManager:
@staticmethod
def connect_to_net(
schematic_path: Path, component_ref: str, pin_name: str, net_name: str
) -> bool:
) -> Dict[str, Any]:
"""
Connect a component pin to a named net using a wire stub and label
Connect a component pin to a named net using a wire stub and label.
Args:
schematic_path: Path to .kicad_sch file
@@ -70,27 +70,32 @@ class ConnectionManager:
net_name: Name of the net to connect to (e.g., "VCC", "GND", "SIGNAL_1")
Returns:
True if successful, False otherwise
Dict with keys:
success bool
pin_location [x, y] exact pin endpoint used (present on success)
label_location [x, y] where the net label was placed (present on success)
wire_stub [[x1,y1],[x2,y2]] the wire segment added (present on success)
message human-readable status
"""
try:
if not WIRE_MANAGER_AVAILABLE:
logger.error("WireManager/PinLocator not available")
return False
return {"success": False, "message": "WireManager/PinLocator not available"}
locator = ConnectionManager.get_pin_locator()
if not locator:
logger.error("Pin locator unavailable")
return False
return {"success": False, "message": "Pin locator unavailable"}
# 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
msg = f"Could not locate pin {component_ref}/{pin_name}"
logger.error(msg)
return {"success": False, "message": msg}
# 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:
@@ -106,26 +111,34 @@ class ConnectionManager:
# 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
msg = "Failed to create wire stub for net connection"
logger.error(msg)
return {"success": False, "message": msg}
# 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
msg = f"Failed to add net label '{net_name}'"
logger.error(msg)
return {"success": False, "message": msg}
logger.info(f"Connected {component_ref}/{pin_name} to net '{net_name}'")
return True
return {
"success": True,
"message": f"Connected {component_ref}/{pin_name} to net '{net_name}'",
"pin_location": pin_loc,
"label_location": stub_end,
"wire_stub": [pin_loc, stub_end],
}
except Exception as e:
logger.error(f"Error connecting to net: {e}")
import traceback
logger.error(traceback.format_exc())
return False
return {"success": False, "message": str(e)}
@staticmethod
def connect_passthrough(
@@ -177,18 +190,18 @@ class ConnectionManager:
else f"{net_prefix}_{pin_num}"
)
ok_src = ConnectionManager.connect_to_net(
res_src = ConnectionManager.connect_to_net(
schematic_path, source_ref, pin_num, net_name
)
if not ok_src:
if not res_src.get("success"):
failed.append(f"{source_ref}/{pin_num}")
continue
if pin_num in tgt_pins:
ok_tgt = ConnectionManager.connect_to_net(
res_tgt = ConnectionManager.connect_to_net(
schematic_path, target_ref, pin_num, net_name
)
if not ok_tgt:
if not res_tgt.get("success"):
failed.append(f"{target_ref}/{pin_num}")
continue
else:

View File

@@ -1514,9 +1514,16 @@ class KiCADInterface:
return {"success": False, "message": str(e)}
def _handle_add_schematic_net_label(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add a net label to schematic using WireManager"""
"""Add a net label to schematic using WireManager.
When componentRef and pinNumber are supplied the label is placed at the
exact pin endpoint retrieved via PinLocator, ignoring the provided
position. The response includes the actual coordinates used and
whether the label landed on a pin endpoint.
"""
logger.info("Adding net label to schematic")
try:
import traceback
from pathlib import Path
from commands.wire_manager import WireManager
@@ -1524,13 +1531,48 @@ class KiCADInterface:
schematic_path = params.get("schematicPath")
net_name = params.get("netName")
position = params.get("position")
label_type = params.get(
"labelType", "label"
) # 'label', 'global_label', 'hierarchical_label'
orientation = params.get("orientation", 0) # 0, 90, 180, 270
label_type = params.get("labelType", "label")
orientation = params.get("orientation", 0)
component_ref = params.get("componentRef")
pin_number = params.get("pinNumber")
if not all([schematic_path, net_name, position]):
return {"success": False, "message": "Missing required parameters"}
if not all([schematic_path, net_name]):
return {
"success": False,
"message": "Missing required parameters: schematicPath, netName",
}
snapped_to_pin: Optional[Dict[str, Any]] = None
if component_ref and pin_number:
# Snap position to exact pin endpoint using PinLocator
from commands.pin_locator import PinLocator
locator = PinLocator()
pin_loc = locator.get_pin_location(
Path(schematic_path), component_ref, str(pin_number)
)
if pin_loc is None:
return {
"success": False,
"message": (
f"Could not locate pin {pin_number} on {component_ref}. "
"Check the reference and pin number."
),
}
position = pin_loc
snapped_to_pin = {"component": component_ref, "pin": str(pin_number)}
logger.info(
f"Snapped label '{net_name}' to pin {component_ref}/{pin_number} at {position}"
)
elif position is None:
return {
"success": False,
"message": (
"Missing position. Either provide position [x, y] or "
"componentRef + pinNumber to snap to a pin endpoint."
),
}
# Use WireManager for S-expression manipulation
success = WireManager.add_label(
@@ -1541,13 +1583,22 @@ class KiCADInterface:
orientation=orientation,
)
if success:
return {
"success": True,
"message": f"Added net label '{net_name}' at {position}",
}
else:
if not success:
return {"success": False, "message": "Failed to add net label"}
response: Dict[str, Any] = {
"success": True,
"message": f"Added net label '{net_name}' at {position}",
"actual_position": position,
}
if snapped_to_pin:
response["snapped_to_pin"] = snapped_to_pin
response["message"] = (
f"Added net label '{net_name}' at exact pin endpoint "
f"{component_ref}/{pin_number} ({position[0]}, {position[1]})"
)
return response
except Exception as e:
logger.error(f"Error adding net label: {str(e)}")
import traceback
@@ -1574,17 +1625,10 @@ class KiCADInterface:
return {"success": False, "message": "Missing required parameters"}
# Use ConnectionManager with new WireManager integration
success = ConnectionManager.connect_to_net(
result = ConnectionManager.connect_to_net(
Path(schematic_path), component_ref, pin_name, net_name
)
if success:
return {
"success": True,
"message": f"Connected {component_ref}/{pin_name} to net '{net_name}'",
}
else:
return {"success": False, "message": "Failed to connect to net"}
return result
except Exception as e:
logger.error(f"Error connecting to net: {str(e)}")
import traceback

View File

@@ -1399,7 +1399,15 @@ SCHEMATIC_TOOLS = [
{
"name": "add_schematic_net_label",
"title": "Add Net Label",
"description": "Adds a net label at exact coordinates on a schematic wire or pin endpoint. WARNING: x/y must match an existing wire endpoint or pin endpoint exactly — placing the label even 0.01mm away from a pin will result in an unconnected pin ERC error. To connect a component pin to a net by reference and pin number (recommended), use connect_to_net instead.",
"description": (
"Add a net label to a schematic. "
"PREFERRED: supply componentRef + pinNumber to snap the label to the exact pin endpoint — "
"this guarantees an electrical connection. "
"Alternatively supply position [x, y], but the coordinates must match the pin endpoint exactly "
"(even a 0.01 mm offset breaks the connection). "
"The response includes actual_position (coordinates actually used) and snapped_to_pin "
"(present when a pin reference was resolved)."
),
"inputSchema": {
"type": "object",
"properties": {
@@ -1411,21 +1419,45 @@ SCHEMATIC_TOOLS = [
"type": "string",
"description": "Name of the net (e.g., VCC, GND, SDA)",
},
"x": {"type": "number", "description": "X coordinate on schematic"},
"y": {"type": "number", "description": "Y coordinate on schematic"},
"rotation": {
"position": {
"type": "array",
"items": {"type": "number"},
"minItems": 2,
"maxItems": 2,
"description": "Position [x, y] for the label. Required when componentRef/pinNumber are not given.",
},
"componentRef": {
"type": "string",
"description": "Component reference to snap label to (e.g. U1, R1). Use with pinNumber.",
},
"pinNumber": {
"type": "string",
"description": "Pin number or name on componentRef (e.g. '1', 'GND'). Use with componentRef.",
},
"labelType": {
"type": "string",
"enum": ["label", "global_label", "hierarchical_label"],
"description": "Label type (default: label)",
"default": "label",
},
"orientation": {
"type": "number",
"description": "Rotation angle in degrees (0, 90, 180, 270)",
"default": 0,
},
},
"required": ["schematicPath", "netName", "x", "y"],
"required": ["schematicPath", "netName"],
},
},
{
"name": "connect_to_net",
"title": "Connect Pin to Net",
"description": "Intelligently connects a component pin to a named net, automatically routing wires as needed.",
"description": (
"Connect a component pin to a named net by adding a wire stub and net label at the exact "
"pin endpoint. The response includes pin_location (exact pin coords), label_location "
"(where the label was placed), and wire_stub (the wire segment added) so you can confirm "
"the placement without a separate verification call."
),
"inputSchema": {
"type": "object",
"properties": {
@@ -1433,11 +1465,11 @@ SCHEMATIC_TOOLS = [
"type": "string",
"description": "Path to schematic file",
},
"reference": {
"componentRef": {
"type": "string",
"description": "Component reference designator (e.g., R1, U3)",
},
"pinNumber": {
"pinName": {
"type": "string",
"description": "Pin number or name on the component",
},
@@ -1446,7 +1478,7 @@ SCHEMATIC_TOOLS = [
"description": "Name of the net to connect to",
},
},
"required": ["schematicPath", "reference", "pinNumber", "netName"],
"required": ["schematicPath", "componentRef", "pinName", "netName"],
},
},
{