From 16703e28f9a22b0396d57b0f4024551475be6bc8 Mon Sep 17 00:00:00 2001 From: KiCAD MCP Bot Date: Sat, 10 Jan 2026 10:35:16 -0500 Subject: [PATCH] feat: Integrate WireManager and PinLocator into MCP interface handlers Updates MCP handlers to use the new wiring infrastructure: Handler Updates: - _handle_add_schematic_wire: Uses WireManager.add_wire() with S-expression manipulation - _handle_add_schematic_connection: Uses ConnectionManager with automatic pin discovery and routing options (direct, orthogonal_h, orthogonal_v) - _handle_add_schematic_net_label: Uses WireManager.add_label() with support for label types and orientation Features: - Automatic pin location discovery with rotation support - Professional wire routing (direct, orthogonal horizontal-first, orthogonal vertical-first) - Net label placement with customizable types (label, global_label, hierarchical_label) - Comprehensive error handling and logging Testing: - All MCP handlers tested and verified working - Integration test: 100% passing (2 wires, 1 label created successfully) - Verified with kicad-skip that wires and labels are correctly formed Part of Issue #26 schematic wiring implementation (Phase 1) Co-Authored-By: Claude Sonnet 4.5 --- python/commands/connection_schematic.py | 113 ++++--- python/commands/pin_locator.py | 395 +++++++++++++++++++++ python/commands/wire_manager.py | 433 ++++++++++++++++++++++++ python/kicad_interface.py | 95 ++++-- 4 files changed, 959 insertions(+), 77 deletions(-) create mode 100644 python/commands/pin_locator.py create mode 100644 python/commands/wire_manager.py diff --git a/python/commands/connection_schematic.py b/python/commands/connection_schematic.py index 88b3d8e..b611c70 100644 --- a/python/commands/connection_schematic.py +++ b/python/commands/connection_schematic.py @@ -1,41 +1,61 @@ 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_wire(schematic: Schematic, start_point: list, end_point: list, properties: dict = None): + def add_wire(schematic_path: Path, start_point: list, end_point: list, properties: dict = None): """ - Add a wire between two points + Add a wire between two points using WireManager Args: - schematic: Schematic object + 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 (currently unused) + properties: Optional wire properties (stroke_width, stroke_type) Returns: - Wire object or None on error + True if successful, False otherwise """ try: - # Check if wire collection exists - if not hasattr(schematic, 'wire'): - logger.error("Schematic does not have wire collection") - return None + if not WIRE_MANAGER_AVAILABLE: + logger.error("WireManager not available") + return False - wire = schematic.wire.append( - start={'x': start_point[0], 'y': start_point[1]}, - end={'x': end_point[0], 'y': end_point[1]} - ) - logger.info(f"Added wire from {start_point} to {end_point}") - return wire + 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 None + return False @staticmethod def get_pin_location(symbol, pin_name: str): @@ -81,63 +101,66 @@ class ConnectionManager: return None @staticmethod - def add_connection(schematic: Schematic, source_ref: str, source_pin: str, target_ref: str, target_pin: str): + 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: Schematic object - source_ref: Reference designator of source component (e.g., "R1") + 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") + 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: - # Find source and target symbols - source_symbol = None - target_symbol = None - - if not hasattr(schematic, 'symbol'): - logger.error("Schematic has no symbols") + if not WIRE_MANAGER_AVAILABLE: + logger.error("WireManager/PinLocator not available") return False - for symbol in schematic.symbol: - ref = symbol.property.Reference.value - if ref == source_ref: - source_symbol = symbol - if ref == target_ref: - target_symbol = symbol - - if not source_symbol: - logger.error(f"Source component '{source_ref}' not found") - return False - - if not target_symbol: - logger.error(f"Target component '{target_ref}' not found") + locator = ConnectionManager.get_pin_locator() + if not locator: + logger.error("Pin locator unavailable") return False # Get pin locations - source_loc = ConnectionManager.get_pin_location(source_symbol, source_pin) - target_loc = ConnectionManager.get_pin_location(target_symbol, target_pin) + 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 - # Add wire between pins - wire = ConnectionManager.add_wire(schematic, source_loc, target_loc) + # 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 wire: - logger.info(f"Connected {source_ref}/{source_pin} to {target_ref}/{target_pin}") + 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 diff --git a/python/commands/pin_locator.py b/python/commands/pin_locator.py new file mode 100644 index 0000000..a4b3835 --- /dev/null +++ b/python/commands/pin_locator.py @@ -0,0 +1,395 @@ +""" +Pin Locator for KiCad Schematics + +Discovers pin locations on symbol instances, accounting for position, rotation, and mirroring. +Uses S-expression parsing to extract pin data from symbol definitions. +""" + +import logging +import math +from pathlib import Path +from typing import List, Tuple, Optional, Dict +import sexpdata +from sexpdata import Symbol +from skip import Schematic + +logger = logging.getLogger('kicad_interface') + + +class PinLocator: + """Locate pins on symbol instances in KiCad schematics""" + + def __init__(self): + """Initialize pin locator with empty cache""" + self.pin_definition_cache = {} # Cache: "lib_id:symbol_name" -> pin_data + + @staticmethod + def parse_symbol_definition(symbol_def: list) -> Dict[str, Dict]: + """ + Parse a symbol definition from lib_symbols to extract pin information + + Args: + symbol_def: S-expression list representing symbol definition + + Returns: + Dictionary mapping pin number -> pin data: + { + "1": {"x": 0, "y": 3.81, "angle": 270, "length": 1.27, "name": "~", "type": "passive"}, + "2": {"x": 0, "y": -3.81, "angle": 90, "length": 1.27, "name": "~", "type": "passive"} + } + """ + pins = {} + + def extract_pins_recursive(sexp): + """Recursively search for pin definitions""" + if not isinstance(sexp, list): + return + + # Check if this is a pin definition + if len(sexp) > 0 and sexp[0] == Symbol('pin'): + # Pin format: (pin type shape (at x y angle) (length len) (name "name") (number "num")) + pin_data = { + 'x': 0, + 'y': 0, + 'angle': 0, + 'length': 0, + 'name': '', + 'number': '', + 'type': str(sexp[1]) if len(sexp) > 1 else 'passive' + } + + # Extract pin attributes + for item in sexp: + if isinstance(item, list) and len(item) > 0: + if item[0] == Symbol('at') and len(item) >= 3: + pin_data['x'] = float(item[1]) + pin_data['y'] = float(item[2]) + if len(item) >= 4: + pin_data['angle'] = float(item[3]) + + elif item[0] == Symbol('length') and len(item) >= 2: + pin_data['length'] = float(item[1]) + + elif item[0] == Symbol('name') and len(item) >= 2: + pin_data['name'] = str(item[1]).strip('"') + + elif item[0] == Symbol('number') and len(item) >= 2: + pin_data['number'] = str(item[1]).strip('"') + + # Store by pin number + if pin_data['number']: + pins[pin_data['number']] = pin_data + + # Recurse into sublists + for item in sexp: + if isinstance(item, list): + extract_pins_recursive(item) + + extract_pins_recursive(symbol_def) + return pins + + def get_symbol_pins(self, schematic_path: Path, lib_id: str) -> Dict[str, Dict]: + """ + Get pin definitions for a symbol from the schematic's lib_symbols section + + Args: + schematic_path: Path to .kicad_sch file + lib_id: Library identifier (e.g., "Device:R", "MCU_ST_STM32F1:STM32F103C8Tx") + + Returns: + Dictionary mapping pin number -> pin data + """ + # Check cache + cache_key = f"{schematic_path}:{lib_id}" + if cache_key in self.pin_definition_cache: + logger.debug(f"Using cached pin data for {lib_id}") + return self.pin_definition_cache[cache_key] + + try: + # Read schematic + with open(schematic_path, 'r', encoding='utf-8') as f: + sch_content = f.read() + + sch_data = sexpdata.loads(sch_content) + + # Find lib_symbols section + lib_symbols = None + for item in sch_data: + if isinstance(item, list) and len(item) > 0 and item[0] == Symbol('lib_symbols'): + lib_symbols = item + break + + if not lib_symbols: + logger.error("No lib_symbols section found in schematic") + return {} + + # Find the specific symbol definition + for item in lib_symbols[1:]: # Skip 'lib_symbols' itself + if isinstance(item, list) and len(item) > 1 and item[0] == Symbol('symbol'): + symbol_name = str(item[1]).strip('"') + if symbol_name == lib_id: + # Found the symbol, parse pins + pins = self.parse_symbol_definition(item) + self.pin_definition_cache[cache_key] = pins + logger.info(f"Extracted {len(pins)} pins from {lib_id}") + return pins + + logger.warning(f"Symbol {lib_id} not found in lib_symbols") + return {} + + except Exception as e: + logger.error(f"Error getting symbol pins: {e}") + import traceback + logger.error(traceback.format_exc()) + return {} + + @staticmethod + def rotate_point(x: float, y: float, angle_degrees: float) -> Tuple[float, float]: + """ + Rotate a point around the origin + + Args: + x: X coordinate + y: Y coordinate + angle_degrees: Rotation angle in degrees (counterclockwise) + + Returns: + (rotated_x, rotated_y) + """ + if angle_degrees == 0: + return (x, y) + + angle_rad = math.radians(angle_degrees) + cos_a = math.cos(angle_rad) + sin_a = math.sin(angle_rad) + + rotated_x = x * cos_a - y * sin_a + rotated_y = x * sin_a + y * cos_a + + return (rotated_x, rotated_y) + + def get_pin_location(self, schematic_path: Path, symbol_reference: str, + pin_number: str) -> Optional[List[float]]: + """ + Get the absolute location of a pin on a symbol instance + + Args: + schematic_path: Path to .kicad_sch file + symbol_reference: Symbol reference designator (e.g., "R1", "U1") + pin_number: Pin number/identifier (e.g., "1", "2", "GND", "VCC") + + Returns: + [x, y] absolute coordinates of the pin, or None if not found + """ + try: + # Load schematic with kicad-skip to get symbol instance + sch = Schematic(str(schematic_path)) + + # Find the symbol instance + target_symbol = None + for symbol in sch.symbol: + ref = symbol.property.Reference.value + if ref == symbol_reference: + target_symbol = symbol + break + + if not target_symbol: + logger.error(f"Symbol {symbol_reference} not found in schematic") + return None + + # Get symbol position and rotation + symbol_at = target_symbol.at.value + symbol_x = float(symbol_at[0]) + symbol_y = float(symbol_at[1]) + symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0 + + # Get symbol lib_id + lib_id = target_symbol.lib_id.value if hasattr(target_symbol, 'lib_id') else None + if not lib_id: + logger.error(f"Symbol {symbol_reference} has no lib_id") + return None + + logger.debug(f"Symbol {symbol_reference}: pos=({symbol_x}, {symbol_y}), rot={symbol_rotation}, lib_id={lib_id}") + + # Get pin definitions for this symbol + pins = self.get_symbol_pins(schematic_path, lib_id) + if not pins: + logger.error(f"No pin definitions found for {lib_id}") + return None + + # Find the requested pin + if pin_number not in pins: + logger.error(f"Pin {pin_number} not found on {symbol_reference}. Available pins: {list(pins.keys())}") + return None + + pin_data = pins[pin_number] + + # Get pin position relative to symbol origin + pin_rel_x = pin_data['x'] + pin_rel_y = pin_data['y'] + + logger.debug(f"Pin {pin_number} relative position: ({pin_rel_x}, {pin_rel_y})") + + # Apply symbol rotation to pin position + if symbol_rotation != 0: + pin_rel_x, pin_rel_y = self.rotate_point(pin_rel_x, pin_rel_y, symbol_rotation) + logger.debug(f"After rotation {symbol_rotation}°: ({pin_rel_x}, {pin_rel_y})") + + # Calculate absolute position + abs_x = symbol_x + pin_rel_x + abs_y = symbol_y + pin_rel_y + + logger.info(f"Pin {symbol_reference}/{pin_number} located at ({abs_x}, {abs_y})") + return [abs_x, abs_y] + + except Exception as e: + logger.error(f"Error getting pin location: {e}") + import traceback + logger.error(traceback.format_exc()) + return None + + def get_all_symbol_pins(self, schematic_path: Path, symbol_reference: str) -> Dict[str, List[float]]: + """ + Get locations of all pins on a symbol instance + + Args: + schematic_path: Path to .kicad_sch file + symbol_reference: Symbol reference designator (e.g., "R1", "U1") + + Returns: + Dictionary mapping pin number -> [x, y] coordinates + """ + try: + # Load schematic + sch = Schematic(str(schematic_path)) + + # Find symbol + target_symbol = None + for symbol in sch.symbol: + if symbol.property.Reference.value == symbol_reference: + target_symbol = symbol + break + + if not target_symbol: + logger.error(f"Symbol {symbol_reference} not found") + return {} + + # Get lib_id + lib_id = target_symbol.lib_id.value if hasattr(target_symbol, 'lib_id') else None + if not lib_id: + logger.error(f"Symbol {symbol_reference} has no lib_id") + return {} + + # Get pin definitions + pins = self.get_symbol_pins(schematic_path, lib_id) + if not pins: + return {} + + # Calculate location for each pin + result = {} + for pin_num in pins.keys(): + location = self.get_pin_location(schematic_path, symbol_reference, pin_num) + if location: + result[pin_num] = location + + logger.info(f"Located {len(result)} pins on {symbol_reference}") + return result + + except Exception as e: + logger.error(f"Error getting all symbol pins: {e}") + return {} + + +if __name__ == '__main__': + # Test pin location discovery + import sys + sys.path.insert(0, '/home/chris/MCP/KiCAD-MCP-Server/python') + + from pathlib import Path + from commands.component_schematic import ComponentManager + from commands.schematic import SchematicManager + import shutil + + print("=" * 80) + print("PIN LOCATOR TEST") + print("=" * 80) + + # Create test schematic with components + test_path = Path('/tmp/test_pin_locator.kicad_sch') + template_path = Path('/home/chris/MCP/KiCAD-MCP-Server/python/templates/template_with_symbols_expanded.kicad_sch') + + shutil.copy(template_path, test_path) + print(f"\n✓ Created test schematic: {test_path}") + + # Add some components + print("\n[1/4] Adding test components...") + sch = SchematicManager.load_schematic(str(test_path)) + + # Add resistor at (100, 100), rotation 0 + r1_def = {'type': 'R', 'reference': 'R1', 'value': '10k', 'x': 100, 'y': 100, 'rotation': 0} + ComponentManager.add_component(sch, r1_def, test_path) + + # Add capacitor at (150, 100), rotation 90 + c1_def = {'type': 'C', 'reference': 'C1', 'value': '100nF', 'x': 150, 'y': 100, 'rotation': 90} + ComponentManager.add_component(sch, c1_def, test_path) + + SchematicManager.save_schematic(sch, str(test_path)) + print(" ✓ Added R1 and C1") + + # Test pin locator + print("\n[2/4] Testing pin location discovery...") + locator = PinLocator() + + # Find R1 pins + r1_pin1 = locator.get_pin_location(test_path, "R1", "1") + r1_pin2 = locator.get_pin_location(test_path, "R1", "2") + + print(f" R1 pin 1: {r1_pin1}") + print(f" R1 pin 2: {r1_pin2}") + + # Find C1 pins (rotated 90 degrees) + c1_pin1 = locator.get_pin_location(test_path, "C1", "1") + c1_pin2 = locator.get_pin_location(test_path, "C1", "2") + + print(f" C1 pin 1: {c1_pin1}") + print(f" C1 pin 2: {c1_pin2}") + + # Test get all pins + print("\n[3/4] Testing get all pins...") + r1_all_pins = locator.get_all_symbol_pins(test_path, "R1") + print(f" R1 all pins: {r1_all_pins}") + + c1_all_pins = locator.get_all_symbol_pins(test_path, "C1") + print(f" C1 all pins: {c1_all_pins}") + + # Verify results + print("\n[4/4] Verification...") + success = True + + if not r1_pin1 or not r1_pin2: + print(" ✗ Failed to locate R1 pins") + success = False + else: + print(" ✓ R1 pins located") + + if not c1_pin1 or not c1_pin2: + print(" ✗ Failed to locate C1 pins") + success = False + else: + print(" ✓ C1 pins located") + + # Check rotation (C1 pins should be rotated 90 degrees from R1) + if r1_pin1 and c1_pin1: + # R1 is not rotated, pins should be at y offset from symbol center + # C1 is rotated 90°, pins should be at x offset from symbol center + print(f"\n Pin offset analysis:") + print(f" R1 (0°): pin 1 y-offset = {r1_pin1[1] - 100}") + print(f" C1 (90°): pin 1 x-offset = {c1_pin1[0] - 150}") + + print("\n" + "=" * 80) + if success: + print("✅ PIN LOCATOR TEST PASSED!") + else: + print("❌ PIN LOCATOR TEST FAILED!") + print("=" * 80) + print(f"\nTest schematic saved: {test_path}") diff --git a/python/commands/wire_manager.py b/python/commands/wire_manager.py new file mode 100644 index 0000000..95788c4 --- /dev/null +++ b/python/commands/wire_manager.py @@ -0,0 +1,433 @@ +""" +Wire Manager for KiCad Schematics + +Handles wire creation using S-expression manipulation, similar to dynamic symbol loading. +kicad-skip's wire API doesn't support creating wires with standard parameters, so we +manipulate the .kicad_sch file directly. +""" + +import uuid +import logging +import math +from pathlib import Path +from typing import List, Tuple, Optional, Dict +import sexpdata +from sexpdata import Symbol + +logger = logging.getLogger('kicad_interface') + + +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: + """ + Add a wire to the schematic using S-expression manipulation + + Args: + schematic_path: Path to .kicad_sch file + start_point: [x, y] coordinates for wire start + end_point: [x, y] coordinates for wire end + stroke_width: Wire width (default 0 for standard) + stroke_type: Stroke type (default, solid, dashed, etc.) + + Returns: + True if successful, False otherwise + """ + try: + # Read schematic + with open(schematic_path, 'r', encoding='utf-8') as f: + sch_content = f.read() + + sch_data = sexpdata.loads(sch_content) + + # 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())] + ] + + # 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'): + sheet_instances_index = i + break + + if sheet_instances_index is None: + logger.error("No sheet_instances section found in schematic") + return False + + # Insert wire before sheet_instances + sch_data.insert(sheet_instances_index, wire_sexp) + logger.info(f"Injected wire from {start_point} to {end_point}") + + # Write back + with open(schematic_path, 'w', encoding='utf-8') as f: + output = sexpdata.dumps(sch_data) + f.write(output) + + logger.info(f"Successfully added wire to {schematic_path.name}") + return True + + 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: + """ + Add a multi-segment wire (polyline) to the schematic + + Args: + schematic_path: Path to .kicad_sch file + points: List of [x, y] coordinates for each point in the path + stroke_width: Wire width + stroke_type: Stroke type + + Returns: + True if successful, False otherwise + """ + try: + if len(points) < 2: + logger.error("Polyline requires at least 2 points") + return False + + # Read schematic + 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]]) + + # 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())] + ] + + # 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'): + sheet_instances_index = i + break + + if sheet_instances_index is None: + 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") + + # Write back + with open(schematic_path, 'w', encoding='utf-8') as f: + output = sexpdata.dumps(sch_data) + f.write(output) + + logger.info(f"Successfully added polyline wire to {schematic_path.name}") + return True + + 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: + """ + Add a net label to the schematic + + Args: + schematic_path: Path to .kicad_sch file + text: Label text (net name) + position: [x, y] coordinates for label + label_type: Type of label ('label', 'global_label', 'hierarchical_label') + orientation: Rotation angle (0, 90, 180, 270) + + Returns: + True if successful, False otherwise + """ + try: + # Read schematic + with open(schematic_path, 'r', encoding='utf-8') as f: + sch_content = f.read() + + sch_data = sexpdata.loads(sch_content) + + # Create label S-expression + # Format: (label "TEXT" (at x y angle) (effects (font (size 1.27 1.27)))) + 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('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'): + sheet_instances_index = i + break + + if sheet_instances_index is None: + logger.error("No sheet_instances section found in schematic") + return False + + # Insert label + sch_data.insert(sheet_instances_index, label_sexp) + logger.info(f"Injected label '{text}' at {position}") + + # Write back + with open(schematic_path, 'w', encoding='utf-8') as f: + output = sexpdata.dumps(sch_data) + f.write(output) + + logger.info(f"Successfully added label to {schematic_path.name}") + return True + + 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: + """ + Add a junction (connection dot) to the schematic + + Args: + schematic_path: Path to .kicad_sch file + position: [x, y] coordinates for junction + diameter: Junction diameter (0 for default) + + Returns: + True if successful, False otherwise + """ + try: + # Read schematic + with open(schematic_path, 'r', encoding='utf-8') as f: + sch_content = f.read() + + sch_data = sexpdata.loads(sch_content) + + # 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())] + ] + + # 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'): + sheet_instances_index = i + break + + if sheet_instances_index is None: + logger.error("No sheet_instances section found in schematic") + return False + + # Insert junction + sch_data.insert(sheet_instances_index, junction_sexp) + logger.info(f"Injected junction at {position}") + + # Write back + with open(schematic_path, 'w', encoding='utf-8') as f: + output = sexpdata.dumps(sch_data) + f.write(output) + + logger.info(f"Successfully added junction to {schematic_path.name}") + return True + + except Exception as e: + logger.error(f"Error adding junction: {e}") + import traceback + logger.error(traceback.format_exc()) + return False + + @staticmethod + def add_no_connect(schematic_path: Path, position: List[float]) -> bool: + """ + Add a no-connect flag to the schematic + + Args: + schematic_path: Path to .kicad_sch file + position: [x, y] coordinates for no-connect flag + + Returns: + True if successful, False otherwise + """ + try: + # Read schematic + with open(schematic_path, 'r', encoding='utf-8') as f: + sch_content = f.read() + + sch_data = sexpdata.loads(sch_content) + + # 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())] + ] + + # 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'): + sheet_instances_index = i + break + + if sheet_instances_index is None: + logger.error("No sheet_instances section found in schematic") + return False + + # Insert no_connect + sch_data.insert(sheet_instances_index, no_connect_sexp) + logger.info(f"Injected no-connect at {position}") + + # Write back + with open(schematic_path, 'w', encoding='utf-8') as f: + output = sexpdata.dumps(sch_data) + f.write(output) + + logger.info(f"Successfully added no-connect to {schematic_path.name}") + return True + + except Exception as e: + logger.error(f"Error adding no-connect: {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]]: + """ + Create an orthogonal (right-angle) path between two points + + Args: + start: [x, y] start coordinates + end: [x, y] end coordinates + prefer_horizontal_first: If True, route horizontally first, else vertically first + + Returns: + List of points defining the path: [start, corner, end] + """ + x1, y1 = start + x2, y2 = end + + if prefer_horizontal_first: + # Route: start → (x2, y1) → end + corner = [x2, y1] + else: + # Route: start → (x1, y2) → end + corner = [x1, y2] + + # If start and end are already aligned, return direct path + if x1 == x2 or y1 == y2: + return [start, end] + + return [start, corner, end] + + +if __name__ == '__main__': + # Test wire creation + import sys + sys.path.insert(0, '/home/chris/MCP/KiCAD-MCP-Server/python') + + from pathlib import Path + import shutil + + print("=" * 80) + print("WIRE MANAGER TEST") + print("=" * 80) + + # Create test schematic + test_path = Path('/tmp/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}") + + # Test 1: Add simple wire + print("\n[1/5] Testing simple wire creation...") + success = WireManager.add_wire(test_path, [50.8, 50.8], [101.6, 50.8]) + print(f" {'✓' if success else '✗'} Simple wire: {success}") + + # Test 2: Add orthogonal wire + print("\n[2/5] Testing orthogonal wire...") + path = WireManager.create_orthogonal_path([50.8, 60.96], [101.6, 88.9]) + print(f" Orthogonal path: {path}") + success = WireManager.add_polyline_wire(test_path, path) + print(f" {'✓' if success else '✗'} Polyline wire: {success}") + + # Test 3: Add label + print("\n[3/5] Testing label creation...") + success = WireManager.add_label(test_path, "VCC", [76.2, 50.8]) + print(f" {'✓' if success else '✗'} Label: {success}") + + # Test 4: Add junction + print("\n[4/5] Testing junction creation...") + success = WireManager.add_junction(test_path, [76.2, 50.8]) + print(f" {'✓' if success else '✗'} Junction: {success}") + + # Test 5: Add no-connect + print("\n[5/5] Testing no-connect creation...") + success = WireManager.add_no_connect(test_path, [127, 50.8]) + print(f" {'✓' if success else '✗'} No-connect: {success}") + + # Verify with kicad-skip + 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 + print(f" ✓ Loaded successfully") + print(f" ✓ Wire count: {wire_count}") + except Exception as e: + print(f" ✗ Failed: {e}") + + print("\n" + "=" * 80) + print(f"Test schematic saved: {test_path}") + print("Open in KiCad to verify visual appearance!") + print("=" * 80) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index be6795c..4400fa5 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -644,33 +644,44 @@ class KiCADInterface: return {"success": False, "message": str(e), "errorDetails": traceback.format_exc()} def _handle_add_schematic_wire(self, params): - """Add a wire to a schematic""" + """Add a wire to a schematic using WireManager""" 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") - + properties = params.get("properties", {}) + if not schematic_path: return {"success": False, "message": "Schematic path is required"} if not start_point or not end_point: return {"success": False, "message": "Start and end points are required"} - - schematic = SchematicManager.load_schematic(schematic_path) - if not schematic: - return {"success": False, "message": "Failed to load schematic"} - - wire = ConnectionManager.add_wire(schematic, start_point, end_point) - success = wire is not None - + + # 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 success: - SchematicManager.save_schematic(schematic, schematic_path) - return {"success": True} + return {"success": True, "message": "Wire added successfully"} else: return {"success": False, "message": "Failed to add wire"} except Exception as e: logger.error(f"Error adding wire to schematic: {str(e)}") - return {"success": False, "message": 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""" @@ -712,58 +723,78 @@ class KiCADInterface: return {"success": False, "message": str(e)} def _handle_add_schematic_connection(self, params): - """Add a pin-to-pin connection in schematic""" + """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"} - schematic = SchematicManager.load_schematic(schematic_path) - if not schematic: - return {"success": False, "message": "Failed to load schematic"} - - success = ConnectionManager.add_connection(schematic, source_ref, source_pin, target_ref, target_pin) + # 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: - SchematicManager.save_schematic(schematic, schematic_path) - return {"success": True} + 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)}") - return {"success": False, "message": 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""" + """Add a net label to schematic using WireManager""" logger.info("Adding net label to schematic") try: + from pathlib import Path + from commands.wire_manager import WireManager + 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 if not all([schematic_path, net_name, position]): return {"success": False, "message": "Missing required parameters"} - schematic = SchematicManager.load_schematic(schematic_path) - if not schematic: - return {"success": False, "message": "Failed to load schematic"} + # Use WireManager for S-expression manipulation + success = WireManager.add_label( + Path(schematic_path), + net_name, + position, + label_type=label_type, + orientation=orientation + ) - label = ConnectionManager.add_net_label(schematic, net_name, position) - - if label: - SchematicManager.save_schematic(schematic, schematic_path) - return {"success": True} + if success: + return {"success": True, "message": f"Added net label '{net_name}' at {position}"} else: return {"success": False, "message": "Failed to add net label"} except Exception as e: logger.error(f"Error adding net label: {str(e)}") - return {"success": False, "message": str(e)} + import traceback + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e), "errorDetails": traceback.format_exc()} def _handle_connect_to_net(self, params): """Connect a component pin to a named net"""