feat: add wire/junction tools with pin-snapping and T-junction support
- 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>
This commit is contained in:
@@ -30,172 +30,6 @@ class ConnectionManager:
|
||||
cls._pin_locator = PinLocator()
|
||||
return cls._pin_locator
|
||||
|
||||
@staticmethod
|
||||
def add_wire(
|
||||
schematic_path: Path,
|
||||
start_point: list,
|
||||
end_point: list,
|
||||
properties: dict = None,
|
||||
):
|
||||
"""
|
||||
Add a wire between two points using WireManager
|
||||
|
||||
Args:
|
||||
schematic_path: Path to .kicad_sch file
|
||||
start_point: [x, y] coordinates for wire start
|
||||
end_point: [x, y] coordinates for wire end
|
||||
properties: Optional wire properties (stroke_width, stroke_type)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
if not WIRE_MANAGER_AVAILABLE:
|
||||
logger.error("WireManager not available")
|
||||
return False
|
||||
|
||||
stroke_width = properties.get("stroke_width", 0) if properties else 0
|
||||
stroke_type = (
|
||||
properties.get("stroke_type", "default") if properties else "default"
|
||||
)
|
||||
|
||||
success = WireManager.add_wire(
|
||||
schematic_path,
|
||||
start_point,
|
||||
end_point,
|
||||
stroke_width=stroke_width,
|
||||
stroke_type=stroke_type,
|
||||
)
|
||||
return success
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding wire: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_pin_location(symbol, pin_name: str):
|
||||
"""
|
||||
Get the absolute location of a pin on a symbol
|
||||
|
||||
Args:
|
||||
symbol: Symbol object
|
||||
pin_name: Name or number of the pin (e.g., "1", "GND", "VCC")
|
||||
|
||||
Returns:
|
||||
[x, y] coordinates or None if pin not found
|
||||
"""
|
||||
try:
|
||||
if not hasattr(symbol, "pin"):
|
||||
logger.warning(f"Symbol {symbol.property.Reference.value} has no pins")
|
||||
return None
|
||||
|
||||
# Find the pin by name
|
||||
target_pin = None
|
||||
for pin in symbol.pin:
|
||||
if pin.name == pin_name:
|
||||
target_pin = pin
|
||||
break
|
||||
|
||||
if not target_pin:
|
||||
logger.warning(
|
||||
f"Pin '{pin_name}' not found on {symbol.property.Reference.value}"
|
||||
)
|
||||
return None
|
||||
|
||||
# Get pin location relative to symbol
|
||||
pin_loc = target_pin.location
|
||||
# Get symbol location
|
||||
symbol_at = symbol.at.value
|
||||
|
||||
# Calculate absolute position
|
||||
# pin_loc is relative to symbol origin, need to add symbol position
|
||||
abs_x = symbol_at[0] + pin_loc[0]
|
||||
abs_y = symbol_at[1] + pin_loc[1]
|
||||
|
||||
return [abs_x, abs_y]
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting pin location: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def add_connection(
|
||||
schematic_path: Path,
|
||||
source_ref: str,
|
||||
source_pin: str,
|
||||
target_ref: str,
|
||||
target_pin: str,
|
||||
routing: str = "direct",
|
||||
):
|
||||
"""
|
||||
Add a wire connection between two component pins
|
||||
|
||||
Args:
|
||||
schematic_path: Path to .kicad_sch file
|
||||
source_ref: Reference designator of source component (e.g., "R1", "R1_")
|
||||
source_pin: Pin name/number on source component
|
||||
target_ref: Reference designator of target component (e.g., "C1", "C1_")
|
||||
target_pin: Pin name/number on target component
|
||||
routing: Routing style ('direct', 'orthogonal_h', 'orthogonal_v')
|
||||
|
||||
Returns:
|
||||
True if connection was 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 locations
|
||||
source_loc = locator.get_pin_location(
|
||||
schematic_path, source_ref, source_pin
|
||||
)
|
||||
target_loc = locator.get_pin_location(
|
||||
schematic_path, target_ref, target_pin
|
||||
)
|
||||
|
||||
if not source_loc or not target_loc:
|
||||
logger.error("Could not determine pin locations")
|
||||
return False
|
||||
|
||||
# Create wire based on routing style
|
||||
if routing == "direct":
|
||||
# Simple direct wire
|
||||
success = WireManager.add_wire(schematic_path, source_loc, target_loc)
|
||||
elif routing == "orthogonal_h":
|
||||
# Orthogonal routing (horizontal first)
|
||||
path = WireManager.create_orthogonal_path(
|
||||
source_loc, target_loc, prefer_horizontal_first=True
|
||||
)
|
||||
success = WireManager.add_polyline_wire(schematic_path, path)
|
||||
elif routing == "orthogonal_v":
|
||||
# Orthogonal routing (vertical first)
|
||||
path = WireManager.create_orthogonal_path(
|
||||
source_loc, target_loc, prefer_horizontal_first=False
|
||||
)
|
||||
success = WireManager.add_polyline_wire(schematic_path, path)
|
||||
else:
|
||||
logger.error(f"Unknown routing style: {routing}")
|
||||
return False
|
||||
|
||||
if success:
|
||||
logger.info(
|
||||
f"Connected {source_ref}/{source_pin} to {target_ref}/{target_pin} (routing: {routing})"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding connection: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def add_net_label(schematic: Schematic, net_name: str, position: list):
|
||||
"""
|
||||
@@ -257,15 +91,20 @@ class ConnectionManager:
|
||||
|
||||
# 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)
|
||||
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
|
||||
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)]
|
||||
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)
|
||||
@@ -333,9 +172,15 @@ class ConnectionManager:
|
||||
connected = []
|
||||
failed = []
|
||||
|
||||
for pin_num in sorted(src_pins.keys(), key=lambda x: int(x) if x.isdigit() else 0):
|
||||
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}"
|
||||
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
|
||||
@@ -355,11 +200,15 @@ class ConnectionManager:
|
||||
failed.append(f"{target_ref}/{pin_num} (pin not found)")
|
||||
continue
|
||||
|
||||
connected.append(f"{source_ref}/{pin_num} <-> {target_ref}/{pin_num} [{net_name}]")
|
||||
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")
|
||||
logger.info(
|
||||
f"connect_passthrough: {len(connected)} connected, {len(failed)} failed"
|
||||
)
|
||||
return {"connected": connected, "failed": failed}
|
||||
|
||||
@staticmethod
|
||||
@@ -607,35 +456,3 @@ class ConnectionManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating netlist: {e}")
|
||||
return {"nets": [], "components": []}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example Usage (for testing)
|
||||
from schematic import (
|
||||
SchematicManager,
|
||||
) # Assuming schematic.py is in the same directory
|
||||
|
||||
# Create a new schematic
|
||||
test_sch = SchematicManager.create_schematic("ConnectionTestSchematic")
|
||||
|
||||
# Add some wires
|
||||
wire1 = ConnectionManager.add_wire(test_sch, [100, 100], [200, 100])
|
||||
wire2 = ConnectionManager.add_wire(test_sch, [200, 100], [200, 200])
|
||||
|
||||
# Note: add_connection, remove_connection, get_net_connections are placeholders
|
||||
# and require more complex implementation based on kicad-skip's structure.
|
||||
|
||||
# Example of how you might add a net label (requires finding a point on a wire)
|
||||
# from skip import Label
|
||||
# if wire1:
|
||||
# net_label_pos = wire1.start # Or calculate a point on the wire
|
||||
# net_label = test_sch.add_label(text="Net_01", at=net_label_pos)
|
||||
# print(f"Added net label 'Net_01' at {net_label_pos}")
|
||||
|
||||
# Save the schematic (optional)
|
||||
# SchematicManager.save_schematic(test_sch, "connection_test.kicad_sch")
|
||||
|
||||
# Clean up (if saved)
|
||||
# if os.path.exists("connection_test.kicad_sch"):
|
||||
# os.remove("connection_test.kicad_sch")
|
||||
# print("Cleaned up connection_test.kicad_sch")
|
||||
|
||||
@@ -11,24 +11,29 @@ import logging
|
||||
import math
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Optional, Dict
|
||||
from typing import List, Tuple, Optional
|
||||
import sexpdata
|
||||
from sexpdata import Symbol
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
logger = logging.getLogger('kicad_interface')
|
||||
|
||||
# Module-level Symbol constants — avoids repeated allocation on every call
|
||||
_SYM_WIRE = Symbol('wire')
|
||||
_SYM_PTS = Symbol('pts')
|
||||
_SYM_XY = Symbol('xy')
|
||||
_SYM_STROKE = Symbol('stroke')
|
||||
_SYM_WIDTH = Symbol('width')
|
||||
_SYM_TYPE = Symbol('type')
|
||||
_SYM_UUID = Symbol('uuid')
|
||||
_SYM_SHEET_INSTANCES = Symbol('sheet_instances')
|
||||
|
||||
|
||||
class WireManager:
|
||||
"""Manage wires in KiCad schematics using S-expression manipulation"""
|
||||
|
||||
@staticmethod
|
||||
def add_wire(
|
||||
schematic_path: Path,
|
||||
start_point: List[float],
|
||||
end_point: List[float],
|
||||
stroke_width: float = 0,
|
||||
stroke_type: str = "default",
|
||||
) -> bool:
|
||||
def add_wire(schematic_path: Path, start_point: List[float], end_point: List[float],
|
||||
stroke_width: float = 0, stroke_type: str = 'default') -> bool:
|
||||
"""
|
||||
Add a wire to the schematic using S-expression manipulation
|
||||
|
||||
@@ -44,36 +49,25 @@ class WireManager:
|
||||
"""
|
||||
try:
|
||||
# Read schematic
|
||||
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||
with open(schematic_path, 'r', encoding='utf-8') as f:
|
||||
sch_content = f.read()
|
||||
|
||||
sch_data = sexpdata.loads(sch_content)
|
||||
|
||||
# Break any existing wire that passes through a new endpoint (T-junction support)
|
||||
for pt in (start_point, end_point):
|
||||
splits = WireManager._break_wires_at_point(sch_data, pt)
|
||||
if splits:
|
||||
logger.info(f"Broke {splits} wire(s) at new wire endpoint {pt}")
|
||||
|
||||
# Create wire S-expression
|
||||
# Format: (wire (pts (xy x1 y1) (xy x2 y2)) (stroke (width N) (type default)) (uuid ...))
|
||||
wire_sexp = [
|
||||
Symbol("wire"),
|
||||
[
|
||||
Symbol("pts"),
|
||||
[Symbol("xy"), start_point[0], start_point[1]],
|
||||
[Symbol("xy"), end_point[0], end_point[1]],
|
||||
],
|
||||
[
|
||||
Symbol("stroke"),
|
||||
[Symbol("width"), stroke_width],
|
||||
[Symbol("type"), Symbol(stroke_type)],
|
||||
],
|
||||
[Symbol("uuid"), str(uuid.uuid4())],
|
||||
]
|
||||
wire_sexp = WireManager._make_wire_sexp(start_point, end_point, stroke_width, stroke_type)
|
||||
|
||||
# Find insertion point (before sheet_instances)
|
||||
sheet_instances_index = None
|
||||
for i, item in enumerate(sch_data):
|
||||
if (
|
||||
isinstance(item, list)
|
||||
and len(item) > 0
|
||||
and item[0] == Symbol("sheet_instances")
|
||||
):
|
||||
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
|
||||
sheet_instances_index = i
|
||||
break
|
||||
|
||||
@@ -86,7 +80,7 @@ class WireManager:
|
||||
logger.info(f"Injected wire from {start_point} to {end_point}")
|
||||
|
||||
# Write back
|
||||
with open(schematic_path, "w", encoding="utf-8") as f:
|
||||
with open(schematic_path, 'w', encoding='utf-8') as f:
|
||||
output = sexpdata.dumps(sch_data)
|
||||
f.write(output)
|
||||
|
||||
@@ -96,17 +90,12 @@ class WireManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding wire: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def add_polyline_wire(
|
||||
schematic_path: Path,
|
||||
points: List[List[float]],
|
||||
stroke_width: float = 0,
|
||||
stroke_type: str = "default",
|
||||
) -> bool:
|
||||
def add_polyline_wire(schematic_path: Path, points: List[List[float]],
|
||||
stroke_width: float = 0, stroke_type: str = 'default') -> bool:
|
||||
"""
|
||||
Add a multi-segment wire (polyline) to the schematic
|
||||
|
||||
@@ -125,36 +114,28 @@ class WireManager:
|
||||
return False
|
||||
|
||||
# Read schematic
|
||||
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||
with open(schematic_path, 'r', encoding='utf-8') as f:
|
||||
sch_content = f.read()
|
||||
|
||||
sch_data = sexpdata.loads(sch_content)
|
||||
|
||||
# Create pts list
|
||||
pts_list = [Symbol("pts")]
|
||||
for point in points:
|
||||
pts_list.append([Symbol("xy"), point[0], point[1]])
|
||||
# Break any existing wire at the outer endpoints of the new path
|
||||
for pt in (points[0], points[-1]):
|
||||
splits = WireManager._break_wires_at_point(sch_data, pt)
|
||||
if splits:
|
||||
logger.info(f"Broke {splits} wire(s) at new polyline endpoint {pt}")
|
||||
|
||||
# Create wire S-expression with multiple points
|
||||
wire_sexp = [
|
||||
Symbol("wire"),
|
||||
pts_list,
|
||||
[
|
||||
Symbol("stroke"),
|
||||
[Symbol("width"), stroke_width],
|
||||
[Symbol("type"), Symbol(stroke_type)],
|
||||
],
|
||||
[Symbol("uuid"), str(uuid.uuid4())],
|
||||
# KiCAD wire elements only support exactly 2 pts each.
|
||||
# Split N waypoints into N-1 individual wire segments.
|
||||
wire_sexps = [
|
||||
WireManager._make_wire_sexp(points[i], points[i + 1], stroke_width, stroke_type)
|
||||
for i in range(len(points) - 1)
|
||||
]
|
||||
|
||||
# Find insertion point
|
||||
sheet_instances_index = None
|
||||
for i, item in enumerate(sch_data):
|
||||
if (
|
||||
isinstance(item, list)
|
||||
and len(item) > 0
|
||||
and item[0] == Symbol("sheet_instances")
|
||||
):
|
||||
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
|
||||
sheet_instances_index = i
|
||||
break
|
||||
|
||||
@@ -162,12 +143,13 @@ class WireManager:
|
||||
logger.error("No sheet_instances section found in schematic")
|
||||
return False
|
||||
|
||||
# Insert wire
|
||||
sch_data.insert(sheet_instances_index, wire_sexp)
|
||||
logger.info(f"Injected polyline wire with {len(points)} points")
|
||||
# Insert all segments (in reverse so order is preserved after inserts)
|
||||
for wire_sexp in reversed(wire_sexps):
|
||||
sch_data.insert(sheet_instances_index, wire_sexp)
|
||||
logger.info(f"Injected {len(wire_sexps)} wire segments for {len(points)}-point polyline")
|
||||
|
||||
# Write back
|
||||
with open(schematic_path, "w", encoding="utf-8") as f:
|
||||
with open(schematic_path, 'w', encoding='utf-8') as f:
|
||||
output = sexpdata.dumps(sch_data)
|
||||
f.write(output)
|
||||
|
||||
@@ -177,18 +159,12 @@ class WireManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding polyline wire: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def add_label(
|
||||
schematic_path: Path,
|
||||
text: str,
|
||||
position: List[float],
|
||||
label_type: str = "label",
|
||||
orientation: int = 0,
|
||||
) -> bool:
|
||||
def add_label(schematic_path: Path, text: str, position: List[float],
|
||||
label_type: str = 'label', orientation: int = 0) -> bool:
|
||||
"""
|
||||
Add a net label to the schematic
|
||||
|
||||
@@ -204,7 +180,7 @@ class WireManager:
|
||||
"""
|
||||
try:
|
||||
# Read schematic
|
||||
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||
with open(schematic_path, 'r', encoding='utf-8') as f:
|
||||
sch_content = f.read()
|
||||
|
||||
sch_data = sexpdata.loads(sch_content)
|
||||
@@ -214,24 +190,19 @@ class WireManager:
|
||||
label_sexp = [
|
||||
Symbol(label_type),
|
||||
text,
|
||||
[Symbol("at"), position[0], position[1], orientation],
|
||||
[Symbol("fields_autoplaced"), Symbol("yes")],
|
||||
[
|
||||
Symbol("effects"),
|
||||
[Symbol("font"), [Symbol("size"), 1.27, 1.27]],
|
||||
[Symbol("justify"), Symbol("left"), Symbol("bottom")],
|
||||
[Symbol('at'), position[0], position[1], orientation],
|
||||
[Symbol('fields_autoplaced'), Symbol('yes')],
|
||||
[Symbol('effects'),
|
||||
[Symbol('font'), [Symbol('size'), 1.27, 1.27]],
|
||||
[Symbol('justify'), Symbol('left'), Symbol('bottom')]
|
||||
],
|
||||
[Symbol("uuid"), str(uuid.uuid4())],
|
||||
[Symbol('uuid'), str(uuid.uuid4())]
|
||||
]
|
||||
|
||||
# Find insertion point
|
||||
sheet_instances_index = None
|
||||
for i, item in enumerate(sch_data):
|
||||
if (
|
||||
isinstance(item, list)
|
||||
and len(item) > 0
|
||||
and item[0] == Symbol("sheet_instances")
|
||||
):
|
||||
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
|
||||
sheet_instances_index = i
|
||||
break
|
||||
|
||||
@@ -244,7 +215,7 @@ class WireManager:
|
||||
logger.info(f"Injected label '{text}' at {position}")
|
||||
|
||||
# Write back
|
||||
with open(schematic_path, "w", encoding="utf-8") as f:
|
||||
with open(schematic_path, 'w', encoding='utf-8') as f:
|
||||
output = sexpdata.dumps(sch_data)
|
||||
f.write(output)
|
||||
|
||||
@@ -254,16 +225,115 @@ class WireManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding label: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def add_junction(
|
||||
schematic_path: Path, position: List[float], diameter: float = 0
|
||||
) -> bool:
|
||||
def _parse_wire(wire_item) -> Optional[Tuple[Tuple[float, float], Tuple[float, float], float, str]]:
|
||||
"""
|
||||
Add a junction (connection dot) to the schematic
|
||||
Parse a wire S-expression item in a single pass.
|
||||
Returns ((x1,y1), (x2,y2), stroke_width, stroke_type), or None if not a valid wire.
|
||||
"""
|
||||
if not (isinstance(wire_item, list) and len(wire_item) >= 2
|
||||
and wire_item[0] == _SYM_WIRE):
|
||||
return None
|
||||
start = end = None
|
||||
stroke_width: float = 0
|
||||
stroke_type: str = 'default'
|
||||
for part in wire_item[1:]:
|
||||
if not isinstance(part, list) or not part:
|
||||
continue
|
||||
tag = part[0]
|
||||
if tag == _SYM_PTS:
|
||||
found: List[Tuple[float, float]] = []
|
||||
for p in part[1:]:
|
||||
if isinstance(p, list) and len(p) >= 3 and p[0] == _SYM_XY:
|
||||
found.append((float(p[1]), float(p[2])))
|
||||
if len(found) == 2:
|
||||
break
|
||||
if len(found) == 2:
|
||||
start, end = found[0], found[1]
|
||||
elif tag == _SYM_STROKE:
|
||||
for sp in part[1:]:
|
||||
if isinstance(sp, list) and len(sp) >= 2:
|
||||
if sp[0] == _SYM_WIDTH:
|
||||
stroke_width = sp[1]
|
||||
elif sp[0] == _SYM_TYPE:
|
||||
stroke_type = str(sp[1])
|
||||
if start is not None and end is not None:
|
||||
return start, end, stroke_width, stroke_type
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _point_strictly_on_wire(px: float, py: float,
|
||||
x1: float, y1: float,
|
||||
x2: float, y2: float,
|
||||
eps: float = 1e-6) -> bool:
|
||||
"""
|
||||
Return True if (px, py) lies strictly between (x1,y1) and (x2,y2)
|
||||
on a horizontal or vertical wire segment (not at either endpoint).
|
||||
"""
|
||||
if abs(y1 - y2) < eps: # horizontal wire
|
||||
if abs(py - y1) > eps:
|
||||
return False
|
||||
lo, hi = min(x1, x2), max(x1, x2)
|
||||
return lo + eps < px < hi - eps
|
||||
if abs(x1 - x2) < eps: # vertical wire
|
||||
if abs(px - x1) > eps:
|
||||
return False
|
||||
lo, hi = min(y1, y2), max(y1, y2)
|
||||
return lo + eps < py < hi - eps
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _make_wire_sexp(start: List[float], end: List[float],
|
||||
stroke_width: float = 0, stroke_type: str = 'default') -> list:
|
||||
return [
|
||||
_SYM_WIRE,
|
||||
[_SYM_PTS,
|
||||
[_SYM_XY, start[0], start[1]],
|
||||
[_SYM_XY, end[0], end[1]]],
|
||||
[_SYM_STROKE,
|
||||
[_SYM_WIDTH, stroke_width],
|
||||
[_SYM_TYPE, Symbol(stroke_type)]],
|
||||
[_SYM_UUID, str(uuid.uuid4())]
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _break_wires_at_point(sch_data: list, position: List[float]) -> int:
|
||||
"""
|
||||
Split any wire segment that passes through *position* as a strict
|
||||
midpoint (i.e. position is not an existing endpoint). Mirrors
|
||||
KiCAD's SCH_LINE_WIRE_BUS_TOOL::BreakSegments behaviour.
|
||||
|
||||
Returns the number of wires split.
|
||||
"""
|
||||
px, py = float(position[0]), float(position[1])
|
||||
splits = 0
|
||||
i = 0
|
||||
while i < len(sch_data):
|
||||
parsed = WireManager._parse_wire(sch_data[i])
|
||||
if parsed is not None:
|
||||
(x1, y1), (x2, y2), stroke_width, stroke_type = parsed
|
||||
if WireManager._point_strictly_on_wire(px, py, x1, y1, x2, y2):
|
||||
seg_a = WireManager._make_wire_sexp([x1, y1], [px, py], stroke_width, stroke_type)
|
||||
seg_b = WireManager._make_wire_sexp([px, py], [x2, y2], stroke_width, stroke_type)
|
||||
sch_data[i:i + 1] = [seg_a, seg_b]
|
||||
logger.info(f"Split wire ({x1},{y1})->({x2},{y2}) at ({px},{py})")
|
||||
splits += 1
|
||||
i += 2 # skip the two new segments
|
||||
continue
|
||||
i += 1
|
||||
return splits
|
||||
|
||||
@staticmethod
|
||||
def add_junction(schematic_path: Path, position: List[float], diameter: float = 0) -> bool:
|
||||
"""
|
||||
Add a junction (connection dot) to the schematic.
|
||||
|
||||
Mirrors KiCAD's AddJunction behaviour: any wire whose interior passes
|
||||
through *position* is split into two segments at that point so that
|
||||
the BFS-based get_wire_connections tool can traverse the T/X branch.
|
||||
|
||||
Args:
|
||||
schematic_path: Path to .kicad_sch file
|
||||
@@ -275,29 +345,31 @@ class WireManager:
|
||||
"""
|
||||
try:
|
||||
# Read schematic
|
||||
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||
with open(schematic_path, 'r', encoding='utf-8') as f:
|
||||
sch_content = f.read()
|
||||
|
||||
sch_data = sexpdata.loads(sch_content)
|
||||
|
||||
# Split any wire that passes through the junction as a midpoint
|
||||
# (mirrors KiCAD's AddJunction / BreakSegments behaviour)
|
||||
splits = WireManager._break_wires_at_point(sch_data, position)
|
||||
if splits:
|
||||
logger.info(f"Broke {splits} wire(s) at junction position {position}")
|
||||
|
||||
# Create junction S-expression
|
||||
# Format: (junction (at x y) (diameter 0) (color 0 0 0 0) (uuid ...))
|
||||
junction_sexp = [
|
||||
Symbol("junction"),
|
||||
[Symbol("at"), position[0], position[1]],
|
||||
[Symbol("diameter"), diameter],
|
||||
[Symbol("color"), 0, 0, 0, 0],
|
||||
[Symbol("uuid"), str(uuid.uuid4())],
|
||||
Symbol('junction'),
|
||||
[Symbol('at'), position[0], position[1]],
|
||||
[Symbol('diameter'), diameter],
|
||||
[Symbol('color'), 0, 0, 0, 0],
|
||||
[Symbol('uuid'), str(uuid.uuid4())]
|
||||
]
|
||||
|
||||
# Find insertion point
|
||||
sheet_instances_index = None
|
||||
for i, item in enumerate(sch_data):
|
||||
if (
|
||||
isinstance(item, list)
|
||||
and len(item) > 0
|
||||
and item[0] == Symbol("sheet_instances")
|
||||
):
|
||||
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
|
||||
sheet_instances_index = i
|
||||
break
|
||||
|
||||
@@ -310,7 +382,7 @@ class WireManager:
|
||||
logger.info(f"Injected junction at {position}")
|
||||
|
||||
# Write back
|
||||
with open(schematic_path, "w", encoding="utf-8") as f:
|
||||
with open(schematic_path, 'w', encoding='utf-8') as f:
|
||||
output = sexpdata.dumps(sch_data)
|
||||
f.write(output)
|
||||
|
||||
@@ -320,7 +392,6 @@ class WireManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding junction: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
@@ -338,7 +409,7 @@ class WireManager:
|
||||
"""
|
||||
try:
|
||||
# Read schematic
|
||||
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||
with open(schematic_path, 'r', encoding='utf-8') as f:
|
||||
sch_content = f.read()
|
||||
|
||||
sch_data = sexpdata.loads(sch_content)
|
||||
@@ -346,19 +417,15 @@ class WireManager:
|
||||
# Create no_connect S-expression
|
||||
# Format: (no_connect (at x y) (uuid ...))
|
||||
no_connect_sexp = [
|
||||
Symbol("no_connect"),
|
||||
[Symbol("at"), position[0], position[1]],
|
||||
[Symbol("uuid"), str(uuid.uuid4())],
|
||||
Symbol('no_connect'),
|
||||
[Symbol('at'), position[0], position[1]],
|
||||
[Symbol('uuid'), str(uuid.uuid4())]
|
||||
]
|
||||
|
||||
# Find insertion point
|
||||
sheet_instances_index = None
|
||||
for i, item in enumerate(sch_data):
|
||||
if (
|
||||
isinstance(item, list)
|
||||
and len(item) > 0
|
||||
and item[0] == Symbol("sheet_instances")
|
||||
):
|
||||
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
|
||||
sheet_instances_index = i
|
||||
break
|
||||
|
||||
@@ -371,7 +438,7 @@ class WireManager:
|
||||
logger.info(f"Injected no-connect at {position}")
|
||||
|
||||
# Write back
|
||||
with open(schematic_path, "w", encoding="utf-8") as f:
|
||||
with open(schematic_path, 'w', encoding='utf-8') as f:
|
||||
output = sexpdata.dumps(sch_data)
|
||||
f.write(output)
|
||||
|
||||
@@ -381,17 +448,12 @@ class WireManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding no-connect: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def delete_wire(
|
||||
schematic_path: Path,
|
||||
start_point: List[float],
|
||||
end_point: List[float],
|
||||
tolerance: float = 0.5,
|
||||
) -> bool:
|
||||
def delete_wire(schematic_path: Path, start_point: List[float], end_point: List[float],
|
||||
tolerance: float = 0.5) -> bool:
|
||||
"""
|
||||
Delete a wire from the schematic matching given start/end coordinates.
|
||||
|
||||
@@ -405,7 +467,7 @@ class WireManager:
|
||||
True if a wire was found and removed, False otherwise
|
||||
"""
|
||||
try:
|
||||
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||
with open(schematic_path, 'r', encoding='utf-8') as f:
|
||||
sch_content = f.read()
|
||||
|
||||
sch_data = sexpdata.loads(sch_content)
|
||||
@@ -414,54 +476,34 @@ class WireManager:
|
||||
ex, ey = end_point
|
||||
|
||||
for i, item in enumerate(sch_data):
|
||||
if not (
|
||||
isinstance(item, list)
|
||||
and len(item) > 0
|
||||
and item[0] == Symbol("wire")
|
||||
):
|
||||
if not (isinstance(item, list) and len(item) > 0 and item[0] == Symbol('wire')):
|
||||
continue
|
||||
|
||||
# Extract pts from the wire s-expression
|
||||
pts_list = None
|
||||
for part in item[1:]:
|
||||
if (
|
||||
isinstance(part, list)
|
||||
and len(part) > 0
|
||||
and part[0] == Symbol("pts")
|
||||
):
|
||||
if isinstance(part, list) and len(part) > 0 and part[0] == Symbol('pts'):
|
||||
pts_list = part
|
||||
break
|
||||
|
||||
if pts_list is None:
|
||||
continue
|
||||
|
||||
xy_points = [
|
||||
p
|
||||
for p in pts_list[1:]
|
||||
if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol("xy")
|
||||
]
|
||||
xy_points = [p for p in pts_list[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol('xy')]
|
||||
if len(xy_points) < 2:
|
||||
continue
|
||||
|
||||
x1, y1 = float(xy_points[0][1]), float(xy_points[0][2])
|
||||
x2, y2 = float(xy_points[-1][1]), float(xy_points[-1][2])
|
||||
|
||||
match_fwd = (
|
||||
abs(x1 - sx) < tolerance
|
||||
and abs(y1 - sy) < tolerance
|
||||
and abs(x2 - ex) < tolerance
|
||||
and abs(y2 - ey) < tolerance
|
||||
)
|
||||
match_rev = (
|
||||
abs(x1 - ex) < tolerance
|
||||
and abs(y1 - ey) < tolerance
|
||||
and abs(x2 - sx) < tolerance
|
||||
and abs(y2 - sy) < tolerance
|
||||
)
|
||||
match_fwd = (abs(x1 - sx) < tolerance and abs(y1 - sy) < tolerance and
|
||||
abs(x2 - ex) < tolerance and abs(y2 - ey) < tolerance)
|
||||
match_rev = (abs(x1 - ex) < tolerance and abs(y1 - ey) < tolerance and
|
||||
abs(x2 - sx) < tolerance and abs(y2 - sy) < tolerance)
|
||||
|
||||
if match_fwd or match_rev:
|
||||
del sch_data[i]
|
||||
with open(schematic_path, "w", encoding="utf-8") as f:
|
||||
with open(schematic_path, 'w', encoding='utf-8') as f:
|
||||
f.write(sexpdata.dumps(sch_data))
|
||||
logger.info(f"Deleted wire from {start_point} to {end_point}")
|
||||
return True
|
||||
@@ -472,17 +514,13 @@ class WireManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting wire: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def delete_label(
|
||||
schematic_path: Path,
|
||||
net_name: str,
|
||||
position: Optional[List[float]] = None,
|
||||
tolerance: float = 0.5,
|
||||
) -> bool:
|
||||
def delete_label(schematic_path: Path, net_name: str,
|
||||
position: Optional[List[float]] = None,
|
||||
tolerance: float = 0.5) -> bool:
|
||||
"""
|
||||
Delete a net label from the schematic by name (and optionally position).
|
||||
|
||||
@@ -496,17 +534,14 @@ class WireManager:
|
||||
True if a label was found and removed, False otherwise
|
||||
"""
|
||||
try:
|
||||
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||
with open(schematic_path, 'r', encoding='utf-8') as f:
|
||||
sch_content = f.read()
|
||||
|
||||
sch_data = sexpdata.loads(sch_content)
|
||||
|
||||
for i, item in enumerate(sch_data):
|
||||
if not (
|
||||
isinstance(item, list)
|
||||
and len(item) > 0
|
||||
and item[0] == Symbol("label")
|
||||
):
|
||||
if not (isinstance(item, list) and len(item) > 0
|
||||
and item[0] == Symbol('label')):
|
||||
continue
|
||||
|
||||
# Second element is the label text
|
||||
@@ -516,26 +551,19 @@ class WireManager:
|
||||
if position is not None:
|
||||
# Find (at x y ...) sub-expression and check coordinates
|
||||
at_entry = next(
|
||||
(
|
||||
p
|
||||
for p in item[1:]
|
||||
if isinstance(p, list)
|
||||
and len(p) >= 3
|
||||
and p[0] == Symbol("at")
|
||||
),
|
||||
(p for p in item[1:] if isinstance(p, list)
|
||||
and len(p) >= 3 and p[0] == Symbol('at')),
|
||||
None,
|
||||
)
|
||||
if at_entry is None:
|
||||
continue
|
||||
lx, ly = float(at_entry[1]), float(at_entry[2])
|
||||
if not (
|
||||
abs(lx - position[0]) < tolerance
|
||||
and abs(ly - position[1]) < tolerance
|
||||
):
|
||||
if not (abs(lx - position[0]) < tolerance
|
||||
and abs(ly - position[1]) < tolerance):
|
||||
continue
|
||||
|
||||
del sch_data[i]
|
||||
with open(schematic_path, "w", encoding="utf-8") as f:
|
||||
with open(schematic_path, 'w', encoding='utf-8') as f:
|
||||
f.write(sexpdata.dumps(sch_data))
|
||||
logger.info(f"Deleted label '{net_name}'")
|
||||
return True
|
||||
@@ -546,14 +574,12 @@ class WireManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting label: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def create_orthogonal_path(
|
||||
start: List[float], end: List[float], prefer_horizontal_first: bool = True
|
||||
) -> List[List[float]]:
|
||||
def create_orthogonal_path(start: List[float], end: List[float],
|
||||
prefer_horizontal_first: bool = True) -> List[List[float]]:
|
||||
"""
|
||||
Create an orthogonal (right-angle) path between two points
|
||||
|
||||
@@ -582,11 +608,10 @@ class WireManager:
|
||||
return [start, corner, end]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == '__main__':
|
||||
# Test wire creation
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/home/chris/MCP/KiCAD-MCP-Server/python")
|
||||
sys.path.insert(0, '/home/chris/MCP/KiCAD-MCP-Server/python')
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
@@ -596,10 +621,8 @@ if __name__ == "__main__":
|
||||
print("=" * 80)
|
||||
|
||||
# Create test schematic (cross-platform temp directory)
|
||||
test_path = Path(tempfile.gettempdir()) / "test_wire_manager.kicad_sch"
|
||||
template_path = Path(
|
||||
"/home/chris/MCP/KiCAD-MCP-Server/python/templates/empty.kicad_sch"
|
||||
)
|
||||
test_path = Path(tempfile.gettempdir()) / 'test_wire_manager.kicad_sch'
|
||||
template_path = Path('/home/chris/MCP/KiCAD-MCP-Server/python/templates/empty.kicad_sch')
|
||||
|
||||
shutil.copy(template_path, test_path)
|
||||
print(f"\n✓ Created test schematic: {test_path}")
|
||||
@@ -635,9 +658,8 @@ if __name__ == "__main__":
|
||||
print("\n[Verification] Loading with kicad-skip...")
|
||||
try:
|
||||
from skip import Schematic
|
||||
|
||||
sch = Schematic(str(test_path))
|
||||
wire_count = len(list(sch.wire)) if hasattr(sch, "wire") else 0
|
||||
wire_count = len(list(sch.wire)) if hasattr(sch, 'wire') else 0
|
||||
print(f" ✓ Loaded successfully")
|
||||
print(f" ✓ Wire count: {wire_count}")
|
||||
except Exception as e:
|
||||
|
||||
@@ -376,8 +376,8 @@ class KiCADInterface:
|
||||
"edit_schematic_component": self._handle_edit_schematic_component,
|
||||
"get_schematic_component": self._handle_get_schematic_component,
|
||||
"add_schematic_wire": self._handle_add_schematic_wire,
|
||||
"add_schematic_connection": self._handle_add_schematic_connection,
|
||||
"add_schematic_net_label": self._handle_add_schematic_net_label,
|
||||
"add_schematic_junction": self._handle_add_schematic_junction,
|
||||
"connect_to_net": self._handle_connect_to_net,
|
||||
"connect_passthrough": self._handle_connect_passthrough,
|
||||
"get_schematic_pin_locations": self._handle_get_schematic_pin_locations,
|
||||
@@ -1137,40 +1137,117 @@ class KiCADInterface:
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_add_schematic_wire(self, params):
|
||||
"""Add a wire to a schematic using WireManager"""
|
||||
"""Add a wire to a schematic using WireManager, with optional pin snapping"""
|
||||
logger.info("Adding wire to schematic")
|
||||
try:
|
||||
from pathlib import Path
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
schematic_path = params.get("schematicPath")
|
||||
start_point = params.get("startPoint")
|
||||
end_point = params.get("endPoint")
|
||||
points = params.get("waypoints")
|
||||
properties = params.get("properties", {})
|
||||
snap_to_pins = params.get("snapToPins", True)
|
||||
snap_tolerance = params.get("snapTolerance", 1.0)
|
||||
|
||||
if not schematic_path:
|
||||
return {"success": False, "message": "Schematic path is required"}
|
||||
if not start_point or not end_point:
|
||||
if not points or len(points) < 2:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Start and end points are required",
|
||||
"message": "At least 2 waypoints are required",
|
||||
}
|
||||
|
||||
# Make a mutable copy of points
|
||||
points = [list(p) for p in points]
|
||||
|
||||
# Pin snapping: adjust first and last endpoints to nearest pin
|
||||
snapped_info = []
|
||||
if snap_to_pins:
|
||||
from commands.pin_locator import PinLocator
|
||||
|
||||
locator = PinLocator()
|
||||
sch_path = Path(schematic_path)
|
||||
|
||||
# Load schematic to iterate all symbols
|
||||
from skip import Schematic as SkipSchematic
|
||||
|
||||
sch = SkipSchematic(str(sch_path))
|
||||
|
||||
# Collect all pin locations: list of (ref, pin_num, [x, y])
|
||||
all_pins = []
|
||||
for symbol in sch.symbol:
|
||||
if not hasattr(symbol.property, "Reference"):
|
||||
continue
|
||||
ref = symbol.property.Reference.value
|
||||
if ref.startswith("_TEMPLATE"):
|
||||
continue
|
||||
pin_locs = locator.get_all_symbol_pins(sch_path, ref)
|
||||
for pin_num, coords in pin_locs.items():
|
||||
all_pins.append((ref, pin_num, coords))
|
||||
|
||||
def find_nearest_pin(point, tolerance):
|
||||
"""Find the nearest pin within tolerance of a point."""
|
||||
best = None
|
||||
best_dist = tolerance
|
||||
for ref, pin_num, coords in all_pins:
|
||||
dx = point[0] - coords[0]
|
||||
dy = point[1] - coords[1]
|
||||
dist = (dx * dx + dy * dy) ** 0.5
|
||||
if dist <= best_dist:
|
||||
best_dist = dist
|
||||
best = (ref, pin_num, coords)
|
||||
return best
|
||||
|
||||
# Snap first endpoint
|
||||
match = find_nearest_pin(points[0], snap_tolerance)
|
||||
if match:
|
||||
ref, pin_num, coords = match
|
||||
logger.info(
|
||||
f"Snapped start point {points[0]} -> {coords} (pin {ref}/{pin_num})"
|
||||
)
|
||||
snapped_info.append(
|
||||
f"start snapped to {ref}/{pin_num} at [{coords[0]}, {coords[1]}]"
|
||||
)
|
||||
points[0] = list(coords)
|
||||
|
||||
# Snap last endpoint
|
||||
match = find_nearest_pin(points[-1], snap_tolerance)
|
||||
if match:
|
||||
ref, pin_num, coords = match
|
||||
logger.info(
|
||||
f"Snapped end point {points[-1]} -> {coords} (pin {ref}/{pin_num})"
|
||||
)
|
||||
snapped_info.append(
|
||||
f"end snapped to {ref}/{pin_num} at [{coords[0]}, {coords[1]}]"
|
||||
)
|
||||
points[-1] = list(coords)
|
||||
|
||||
# Extract wire properties
|
||||
stroke_width = properties.get("stroke_width", 0)
|
||||
stroke_type = properties.get("stroke_type", "default")
|
||||
|
||||
# Use WireManager for S-expression manipulation
|
||||
success = WireManager.add_wire(
|
||||
Path(schematic_path),
|
||||
start_point,
|
||||
end_point,
|
||||
stroke_width=stroke_width,
|
||||
stroke_type=stroke_type,
|
||||
)
|
||||
if len(points) == 2:
|
||||
success = WireManager.add_wire(
|
||||
Path(schematic_path),
|
||||
points[0],
|
||||
points[1],
|
||||
stroke_width=stroke_width,
|
||||
stroke_type=stroke_type,
|
||||
)
|
||||
else:
|
||||
success = WireManager.add_polyline_wire(
|
||||
Path(schematic_path),
|
||||
points,
|
||||
stroke_width=stroke_width,
|
||||
stroke_type=stroke_type,
|
||||
)
|
||||
|
||||
if success:
|
||||
return {"success": True, "message": "Wire added successfully"}
|
||||
message = "Wire added successfully"
|
||||
if snapped_info:
|
||||
message += "; " + "; ".join(snapped_info)
|
||||
return {"success": True, "message": message}
|
||||
else:
|
||||
return {"success": False, "message": "Failed to add wire"}
|
||||
except Exception as e:
|
||||
@@ -1184,6 +1261,38 @@ class KiCADInterface:
|
||||
"errorDetails": traceback.format_exc(),
|
||||
}
|
||||
|
||||
def _handle_add_schematic_junction(self, params):
|
||||
"""Add a junction (connection dot) to a schematic using WireManager"""
|
||||
logger.info("Adding junction to schematic")
|
||||
try:
|
||||
from pathlib import Path
|
||||
from commands.wire_manager import WireManager
|
||||
|
||||
schematic_path = params.get("schematicPath")
|
||||
position = params.get("position")
|
||||
|
||||
if not schematic_path:
|
||||
return {"success": False, "message": "Schematic path is required"}
|
||||
if not position:
|
||||
return {"success": False, "message": "Position is required"}
|
||||
|
||||
success = WireManager.add_junction(Path(schematic_path), position)
|
||||
|
||||
if success:
|
||||
return {"success": True, "message": "Junction added successfully"}
|
||||
else:
|
||||
return {"success": False, "message": "Failed to add junction"}
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding junction to schematic: {str(e)}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return {
|
||||
"success": False,
|
||||
"message": str(e),
|
||||
"errorDetails": traceback.format_exc(),
|
||||
}
|
||||
|
||||
def _handle_list_schematic_libraries(self, params):
|
||||
"""List available symbol libraries"""
|
||||
logger.info("Listing schematic libraries")
|
||||
@@ -1393,54 +1502,6 @@ class KiCADInterface:
|
||||
logger.error(f"Error exporting schematic to PDF: {str(e)}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_add_schematic_connection(self, params):
|
||||
"""Add a pin-to-pin connection in schematic with automatic pin discovery and routing"""
|
||||
logger.info("Adding pin-to-pin connection in schematic")
|
||||
try:
|
||||
from pathlib import Path
|
||||
|
||||
schematic_path = params.get("schematicPath")
|
||||
source_ref = params.get("sourceRef")
|
||||
source_pin = params.get("sourcePin")
|
||||
target_ref = params.get("targetRef")
|
||||
target_pin = params.get("targetPin")
|
||||
routing = params.get(
|
||||
"routing", "direct"
|
||||
) # 'direct', 'orthogonal_h', 'orthogonal_v'
|
||||
|
||||
if not all(
|
||||
[schematic_path, source_ref, source_pin, target_ref, target_pin]
|
||||
):
|
||||
return {"success": False, "message": "Missing required parameters"}
|
||||
|
||||
# Use ConnectionManager with new PinLocator and WireManager integration
|
||||
success = ConnectionManager.add_connection(
|
||||
Path(schematic_path),
|
||||
source_ref,
|
||||
source_pin,
|
||||
target_ref,
|
||||
target_pin,
|
||||
routing=routing,
|
||||
)
|
||||
|
||||
if success:
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Connected {source_ref}/{source_pin} to {target_ref}/{target_pin} (routing: {routing})",
|
||||
}
|
||||
else:
|
||||
return {"success": False, "message": "Failed to add connection"}
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding schematic connection: {str(e)}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return {
|
||||
"success": False,
|
||||
"message": str(e),
|
||||
"errorDetails": traceback.format_exc(),
|
||||
}
|
||||
|
||||
def _handle_add_schematic_net_label(self, params):
|
||||
"""Add a net label to schematic using WireManager"""
|
||||
logger.info("Adding net label to schematic")
|
||||
|
||||
@@ -1362,30 +1362,8 @@ SCHEMATIC_TOOLS = [
|
||||
},
|
||||
{
|
||||
"name": "add_schematic_wire",
|
||||
"title": "Connect Components",
|
||||
"description": "Draws a wire connection between component pins on the schematic.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"points": {
|
||||
"type": "array",
|
||||
"description": "Array of [x, y] waypoints for the wire",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {"type": "number"},
|
||||
"minItems": 2,
|
||||
"maxItems": 2,
|
||||
},
|
||||
"minItems": 2,
|
||||
}
|
||||
},
|
||||
"required": ["points"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "add_schematic_connection",
|
||||
"title": "Add Junction/Connection Point",
|
||||
"description": "Adds a junction (connection point) at the specified location on the schematic where wires cross and should connect.",
|
||||
"title": "Draw Wire Between Pins",
|
||||
"description": "Draws a wire on the schematic between two or more coordinate points. Always call get_schematic_pin_locations first to get the approximate pin coordinates, then pass them as the first and last waypoints. snapToPins (on by default) will correct any float imprecision by snapping endpoints to the exact nearest pin coordinate. To route around components, add intermediate waypoints between the start and end: e.g. [[x1,y1], [xMid,y1], [xMid,y2], [x2,y2]] routes horizontally then vertically. Intermediate waypoints are never snapped.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1393,10 +1371,29 @@ SCHEMATIC_TOOLS = [
|
||||
"type": "string",
|
||||
"description": "Path to schematic file",
|
||||
},
|
||||
"x": {"type": "number", "description": "X coordinate on schematic"},
|
||||
"y": {"type": "number", "description": "Y coordinate on schematic"},
|
||||
"waypoints": {
|
||||
"type": "array",
|
||||
"description": "Array of [x, y] coordinates defining the wire path. First and last points are the pin locations (from get_schematic_pin_locations). Add intermediate points to route around obstacles.",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {"type": "number"},
|
||||
"minItems": 2,
|
||||
"maxItems": 2,
|
||||
},
|
||||
"minItems": 2,
|
||||
},
|
||||
"snapToPins": {
|
||||
"type": "boolean",
|
||||
"description": "When true, the first and last waypoints are snapped to the nearest schematic pin within snapTolerance mm. Intermediate waypoints are left unchanged. Enabled by default to correct float coordinate imprecision.",
|
||||
"default": True,
|
||||
},
|
||||
"snapTolerance": {
|
||||
"type": "number",
|
||||
"description": "Maximum distance in mm to search for a nearby pin when snapToPins is enabled.",
|
||||
"default": 1.0,
|
||||
},
|
||||
},
|
||||
"required": ["schematicPath", "x", "y"],
|
||||
"required": ["schematicPath", "waypoints"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1610,6 +1607,28 @@ SCHEMATIC_TOOLS = [
|
||||
"required": ["schematicPath", "outputPath"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "add_schematic_junction",
|
||||
"title": "Add Junction to Schematic",
|
||||
"description": "Adds a junction (connection dot) at the specified coordinates on the schematic. Junctions are required in KiCAD to mark intentional connections where wires cross or where a wire branches off another wire. Without a junction, crossing wires are not electrically connected.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schematicPath": {
|
||||
"type": "string",
|
||||
"description": "Path to schematic file",
|
||||
},
|
||||
"position": {
|
||||
"type": "array",
|
||||
"description": "The [x, y] coordinates where the junction should be placed. Must be on an existing wire intersection or branch point.",
|
||||
"items": {"type": "number"},
|
||||
"minItems": 2,
|
||||
"maxItems": 2,
|
||||
},
|
||||
},
|
||||
"required": ["schematicPath", "position"],
|
||||
},
|
||||
},
|
||||
# --- Schematic Analysis Tools (read-only) ---
|
||||
{
|
||||
"name": "get_schematic_view_region",
|
||||
|
||||
1038
python/tests/test_wire_junction_changes.py
Normal file
1038
python/tests/test_wire_junction_changes.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -91,9 +91,9 @@ export const toolCategories: ToolCategory[] = [
|
||||
"move_schematic_component",
|
||||
"rotate_schematic_component",
|
||||
"annotate_schematic",
|
||||
"add_wire",
|
||||
"add_schematic_wire",
|
||||
"delete_schematic_wire",
|
||||
"add_schematic_connection",
|
||||
"add_schematic_junction",
|
||||
"add_schematic_net_label",
|
||||
"delete_schematic_net_label",
|
||||
"connect_to_net",
|
||||
|
||||
@@ -43,7 +43,10 @@ export function registerSchematicTools(
|
||||
),
|
||||
reference: z.string().describe("Component reference (e.g., R1, U1)"),
|
||||
value: z.string().optional().describe("Component value"),
|
||||
footprint: z.string().optional().describe("KiCAD footprint (e.g. Resistor_SMD:R_0603_1608Metric)"),
|
||||
footprint: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("KiCAD footprint (e.g. Resistor_SMD:R_0603_1608Metric)"),
|
||||
position: z
|
||||
.object({
|
||||
x: z.number(),
|
||||
@@ -119,7 +122,9 @@ To remove a footprint from a PCB, use delete_component instead.`,
|
||||
schematicPath: z.string().describe("Path to the .kicad_sch file"),
|
||||
reference: z
|
||||
.string()
|
||||
.describe("Reference designator of the component to remove (e.g. R1, U3)"),
|
||||
.describe(
|
||||
"Reference designator of the component to remove (e.g. R1, U3)",
|
||||
),
|
||||
},
|
||||
async (args: { schematicPath: string; reference: string }) => {
|
||||
const result = await callKicadScript("delete_schematic_component", args);
|
||||
@@ -233,66 +238,40 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
|
||||
},
|
||||
);
|
||||
|
||||
// Connect components with wire
|
||||
// Draw wire between coordinate waypoints with optional pin snapping
|
||||
server.tool(
|
||||
"add_wire",
|
||||
"Add a wire connection in the schematic",
|
||||
"add_schematic_wire",
|
||||
"Draws a wire on the schematic between two or more coordinate points. Always call get_schematic_pin_locations first to get the approximate pin coordinates, then pass them as the first and last waypoints. snapToPins (on by default) will correct any float imprecision by snapping endpoints to the exact nearest pin coordinate. To route around components, add intermediate waypoints between the start and end: e.g. [[x1,y1], [xMid,y1], [xMid,y2], [x2,y2]] routes horizontally then vertically. Intermediate waypoints are never snapped.",
|
||||
{
|
||||
start: z
|
||||
.object({
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
})
|
||||
.describe("Start position"),
|
||||
end: z
|
||||
.object({
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
})
|
||||
.describe("End position"),
|
||||
},
|
||||
async (args: any) => {
|
||||
const result = await callKicadScript("add_wire", args);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(result, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Add pin-to-pin connection
|
||||
server.tool(
|
||||
"add_schematic_connection",
|
||||
"Connect two component pins with a wire. Use this for individual connections between components with different pin roles (e.g. U1.SDA → J3.2). WARNING: Do NOT use this in a loop to wire N passthrough pins — use connect_passthrough instead (single call, cleaner layout, far fewer tokens).",
|
||||
{
|
||||
schematicPath: z.string().describe("Path to the schematic file"),
|
||||
sourceRef: z.string().describe("Source component reference (e.g., R1)"),
|
||||
sourcePin: z
|
||||
.string()
|
||||
.describe("Source pin name/number (e.g., 1, 2, GND)"),
|
||||
targetRef: z.string().describe("Target component reference (e.g., C1)"),
|
||||
targetPin: z
|
||||
.string()
|
||||
.describe("Target pin name/number (e.g., 1, 2, VCC)"),
|
||||
schematicPath: z.string().describe("Path to the .kicad_sch file"),
|
||||
waypoints: z
|
||||
.array(z.array(z.number()).length(2))
|
||||
.min(2)
|
||||
.describe("Ordered list of [x, y] coordinates. Minimum 2 points."),
|
||||
snapToPins: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Snap the first and last waypoints to the nearest pin (default: true)",
|
||||
),
|
||||
snapTolerance: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Maximum snap distance in mm (default: 1.0)"),
|
||||
},
|
||||
async (args: {
|
||||
schematicPath: string;
|
||||
sourceRef: string;
|
||||
sourcePin: string;
|
||||
targetRef: string;
|
||||
targetPin: string;
|
||||
waypoints: number[][];
|
||||
snapToPins?: boolean;
|
||||
snapTolerance?: number;
|
||||
}) => {
|
||||
const result = await callKicadScript("add_schematic_connection", args);
|
||||
const result = await callKicadScript("add_schematic_wire", args);
|
||||
if (result.success) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Successfully connected ${args.sourceRef}/${args.sourcePin} to ${args.targetRef}/${args.targetPin}`,
|
||||
type: "text" as const,
|
||||
text: result.message || "Wire added successfully",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -300,8 +279,43 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Failed to add connection: ${result.message || "Unknown error"}`,
|
||||
type: "text" as const,
|
||||
text: `Failed to add wire: ${result.message || "Unknown error"}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Add junction dot at a T/X intersection
|
||||
server.tool(
|
||||
"add_schematic_junction",
|
||||
"Place a junction dot at a wire intersection in the schematic. Required at T-branch and X-cross points so KiCAD recognises the electrical connection.",
|
||||
{
|
||||
schematicPath: z.string().describe("Path to the .kicad_sch file"),
|
||||
position: z
|
||||
.array(z.number())
|
||||
.length(2)
|
||||
.describe("Junction position [x, y] in mm"),
|
||||
},
|
||||
async (args: { schematicPath: string; position: number[] }) => {
|
||||
const result = await callKicadScript("add_schematic_junction", args);
|
||||
if (result.success) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: result.message || "Junction added successfully",
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Failed to add junction: ${result.message || "Unknown error"}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -431,27 +445,33 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
|
||||
"Returns the exact x/y coordinates of every pin on a schematic component. Use this before add_schematic_net_label to place labels correctly on pin endpoints.",
|
||||
{
|
||||
schematicPath: z.string().describe("Path to the schematic file"),
|
||||
reference: z.string().describe("Component reference designator (e.g. U1, R1, J2)"),
|
||||
reference: z
|
||||
.string()
|
||||
.describe("Component reference designator (e.g. U1, R1, J2)"),
|
||||
},
|
||||
async (args: { schematicPath: string; reference: string }) => {
|
||||
const result = await callKicadScript("get_schematic_pin_locations", args);
|
||||
if (result.success && result.pins) {
|
||||
const lines = Object.entries(result.pins as Record<string, any>).map(
|
||||
([pinNum, data]: [string, any]) =>
|
||||
` Pin ${pinNum} (${data.name || pinNum}): x=${data.x}, y=${data.y}, angle=${data.angle ?? 0}°`
|
||||
` Pin ${pinNum} (${data.name || pinNum}): x=${data.x}, y=${data.y}, angle=${data.angle ?? 0}°`,
|
||||
);
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Pin locations for ${args.reference}:\n${lines.join("\n")}`,
|
||||
}],
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Pin locations for ${args.reference}:\n${lines.join("\n")}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Failed to get pin locations: ${result.message || "Unknown error"}`,
|
||||
}],
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Failed to get pin locations: ${result.message || "Unknown error"}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -465,21 +485,49 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
|
||||
schematicPath: z.string().describe("Path to the schematic file"),
|
||||
sourceRef: z.string().describe("Source connector reference (e.g. J1)"),
|
||||
targetRef: z.string().describe("Target connector reference (e.g. J2)"),
|
||||
netPrefix: z.string().optional().describe("Net name prefix, e.g. 'CSI' → CSI_1, CSI_2 (default: PIN)"),
|
||||
pinOffset: z.number().optional().describe("Add to pin number when building net name (default: 0)"),
|
||||
netPrefix: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Net name prefix, e.g. 'CSI' → CSI_1, CSI_2 (default: PIN)"),
|
||||
pinOffset: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Add to pin number when building net name (default: 0)"),
|
||||
},
|
||||
async (args: { schematicPath: string; sourceRef: string; targetRef: string; netPrefix?: string; pinOffset?: number }) => {
|
||||
async (args: {
|
||||
schematicPath: string;
|
||||
sourceRef: string;
|
||||
targetRef: string;
|
||||
netPrefix?: string;
|
||||
pinOffset?: number;
|
||||
}) => {
|
||||
const result = await callKicadScript("connect_passthrough", args);
|
||||
if (result.success !== false || (result.connected && result.connected.length > 0)) {
|
||||
if (
|
||||
result.success !== false ||
|
||||
(result.connected && result.connected.length > 0)
|
||||
) {
|
||||
const lines: string[] = [];
|
||||
if (result.connected?.length) lines.push(`Connected (${result.connected.length}): ${result.connected.slice(0, 5).join(", ")}${result.connected.length > 5 ? " ..." : ""}`);
|
||||
if (result.failed?.length) lines.push(`Failed (${result.failed.length}): ${result.failed.join(", ")}`);
|
||||
if (result.connected?.length)
|
||||
lines.push(
|
||||
`Connected (${result.connected.length}): ${result.connected.slice(0, 5).join(", ")}${result.connected.length > 5 ? " ..." : ""}`,
|
||||
);
|
||||
if (result.failed?.length)
|
||||
lines.push(
|
||||
`Failed (${result.failed.length}): ${result.failed.join(", ")}`,
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text", text: result.message + "\n" + lines.join("\n") }],
|
||||
content: [
|
||||
{ type: "text", text: result.message + "\n" + lines.join("\n") },
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: [{ type: "text", text: `Passthrough failed: ${result.message || "Unknown error"}` }],
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Passthrough failed: ${result.message || "Unknown error"}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -999,21 +1047,30 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
|
||||
"run_erc",
|
||||
"Runs the KiCAD Electrical Rules Check (ERC) on a schematic and returns all violations. Use after wiring to verify the schematic before generating a netlist.",
|
||||
{
|
||||
schematicPath: z.string().describe("Path to the .kicad_sch schematic file"),
|
||||
schematicPath: z
|
||||
.string()
|
||||
.describe("Path to the .kicad_sch schematic file"),
|
||||
},
|
||||
async (args: { schematicPath: string }) => {
|
||||
const result = await callKicadScript("run_erc", args);
|
||||
if (result.success) {
|
||||
const violations: any[] = result.violations || [];
|
||||
const lines: string[] = [`ERC result: ${violations.length} violation(s)`];
|
||||
const lines: string[] = [
|
||||
`ERC result: ${violations.length} violation(s)`,
|
||||
];
|
||||
if (result.summary?.by_severity) {
|
||||
const s = result.summary.by_severity;
|
||||
lines.push(` Errors: ${s.error ?? 0} Warnings: ${s.warning ?? 0} Info: ${s.info ?? 0}`);
|
||||
lines.push(
|
||||
` Errors: ${s.error ?? 0} Warnings: ${s.warning ?? 0} Info: ${s.info ?? 0}`,
|
||||
);
|
||||
}
|
||||
if (violations.length > 0) {
|
||||
lines.push("");
|
||||
violations.slice(0, 30).forEach((v: any, i: number) => {
|
||||
const loc = v.location && (v.location.x !== undefined) ? ` @ (${v.location.x}, ${v.location.y})` : "";
|
||||
const loc =
|
||||
v.location && v.location.x !== undefined
|
||||
? ` @ (${v.location.x}, ${v.location.y})`
|
||||
: "";
|
||||
lines.push(`${i + 1}. [${v.severity}] ${v.message}${loc}`);
|
||||
});
|
||||
if (violations.length > 30) {
|
||||
@@ -1023,7 +1080,12 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
|
||||
return { content: [{ type: "text", text: lines.join("\n") }] };
|
||||
} else {
|
||||
return {
|
||||
content: [{ type: "text", text: `ERC failed: ${result.message || "Unknown error"}${result.errorDetails ? "\n" + result.errorDetails : ""}` }],
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `ERC failed: ${result.message || "Unknown error"}${result.errorDetails ? "\n" + result.errorDetails : ""}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -1082,8 +1144,12 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
|
||||
"sync_schematic_to_board",
|
||||
"Import the schematic netlist into the PCB board — equivalent to pressing F8 in KiCAD (Tools → Update PCB from Schematic). MUST be called after the schematic is complete and before placing or routing components on the PCB. Without this step, the board has no footprints and no net assignments — place_component and route_pad_to_pad will produce an empty, unroutable board.",
|
||||
{
|
||||
schematicPath: z.string().describe("Absolute path to the .kicad_sch schematic file"),
|
||||
boardPath: z.string().describe("Absolute path to the .kicad_pcb board file"),
|
||||
schematicPath: z
|
||||
.string()
|
||||
.describe("Absolute path to the .kicad_sch schematic file"),
|
||||
boardPath: z
|
||||
.string()
|
||||
.describe("Absolute path to the .kicad_pcb board file"),
|
||||
},
|
||||
async (args: { schematicPath: string; boardPath: string }) => {
|
||||
const result = await callKicadScript("sync_schematic_to_board", args);
|
||||
|
||||
Reference in New Issue
Block a user