diff --git a/python/commands/connection_schematic.py b/python/commands/connection_schematic.py index 82807f2..b4d04d0 100644 --- a/python/commands/connection_schematic.py +++ b/python/commands/connection_schematic.py @@ -1,455 +1,463 @@ -import logging -import os -from pathlib import Path -from typing import Any, Dict, List, Optional - -from skip import Schematic - -logger = logging.getLogger(__name__) - -# Import new wire and pin managers -try: - from commands.pin_locator import PinLocator - from commands.wire_manager import WireManager - - 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) -> Any: - """Get or create pin locator instance""" - if cls._pin_locator is None and WIRE_MANAGER_AVAILABLE: - cls._pin_locator = PinLocator() - return cls._pin_locator - - @staticmethod - def add_net_label(schematic: Schematic, net_name: str, position: list) -> Any: - """ - Add a net label to the schematic - - Args: - schematic: Schematic object - net_name: Name of the net (e.g., "VCC", "GND", "SIGNAL_1") - position: [x, y] coordinates for the label - - Returns: - Label object or None on error - """ - try: - if not hasattr(schematic, "label"): - logger.error("Schematic does not have label collection") - return None - - label = schematic.label.append(text=net_name, at={"x": position[0], "y": position[1]}) - logger.info(f"Added net label '{net_name}' at {position}") - return label - except Exception as e: - logger.error(f"Error adding net label: {e}") - return None - - @staticmethod - def connect_to_net( - schematic_path: Path, component_ref: str, pin_name: str, net_name: str - ) -> Dict[str, Any]: - """ - Connect a component pin to a named net using a wire stub and label. - - Args: - schematic_path: Path to .kicad_sch file - component_ref: Reference designator (e.g., "U1", "U1_") - pin_name: Pin name/number - net_name: Name of the net to connect to (e.g., "VCC", "GND", "SIGNAL_1") - - Returns: - Dict with keys: - success – bool - pin_location – [x, y] exact pin endpoint used (present on success) - label_location – [x, y] where the net label was placed (present on success) - wire_stub – [[x1,y1],[x2,y2]] the wire segment added (present on success) - message – human-readable status - """ - try: - if not WIRE_MANAGER_AVAILABLE: - logger.error("WireManager/PinLocator not available") - return {"success": False, "message": "WireManager/PinLocator not available"} - - locator = ConnectionManager.get_pin_locator() - if not locator: - logger.error("Pin locator unavailable") - return {"success": False, "message": "Pin locator unavailable"} - - # Get pin location using PinLocator - pin_loc = locator.get_pin_location(schematic_path, component_ref, pin_name) - if not pin_loc: - msg = f"Could not locate pin {component_ref}/{pin_name}" - logger.error(msg) - return {"success": False, "message": msg} - - # Add a small wire stub from the pin (2.54mm = 0.1 inch, standard grid spacing) - # Stub direction follows the pin's outward angle from the PinLocator - try: - pin_angle_deg = locator.get_pin_angle(schematic_path, component_ref, pin_name) or 0 - except Exception as e: - logger.warning( - f"Could not get pin angle for {component_ref}/{pin_name}, defaulting to 0: {e}" - ) - pin_angle_deg = 0 - import math as _math - - angle_rad = _math.radians(pin_angle_deg) - stub_end = [ - round(pin_loc[0] + 2.54 * _math.cos(angle_rad), 4), - round(pin_loc[1] - 2.54 * _math.sin(angle_rad), 4), - ] - - # Create wire stub using WireManager - wire_success = WireManager.add_wire(schematic_path, pin_loc, stub_end) - if not wire_success: - msg = "Failed to create wire stub for net connection" - logger.error(msg) - return {"success": False, "message": msg} - - # Add label at the end of the stub using WireManager - label_success = WireManager.add_label( - schematic_path, net_name, stub_end, label_type="label" - ) - if not label_success: - msg = f"Failed to add net label '{net_name}'" - logger.error(msg) - return {"success": False, "message": msg} - - logger.info(f"Connected {component_ref}/{pin_name} to net '{net_name}'") - return { - "success": True, - "message": f"Connected {component_ref}/{pin_name} to net '{net_name}'", - "pin_location": pin_loc, - "label_location": stub_end, - "wire_stub": [pin_loc, stub_end], - } - - except Exception as e: - logger.error(f"Error connecting to net: {e}") - import traceback - - logger.error(traceback.format_exc()) - return {"success": False, "message": str(e)} - - @staticmethod - def connect_passthrough( - schematic_path: Path, - source_ref: str, - target_ref: str, - net_prefix: str = "PIN", - pin_offset: int = 0, - ) -> Dict[str, List[str]]: - """ - Connect all pins of source_ref to matching pins of target_ref via shared net labels. - Useful for passthrough adapters: J1 pin N <-> J2 pin N on net {net_prefix}_{N}. - - Args: - schematic_path: Path to .kicad_sch file - source_ref: Reference of the first connector (e.g., "J1") - target_ref: Reference of the second connector (e.g., "J2") - net_prefix: Prefix for generated net names (default: "PIN" -> PIN_1, PIN_2, ...) - pin_offset: Add this value to the pin number when building the net name (default 0) - - Returns: - dict with 'connected' list and 'failed' list - """ - if not WIRE_MANAGER_AVAILABLE: - logger.error("WireManager/PinLocator not available") - return {"connected": [], "failed": ["WireManager unavailable"]} - - locator = ConnectionManager.get_pin_locator() - if not locator: - return {"connected": [], "failed": ["PinLocator unavailable"]} - - # Get all pins of source and target - src_pins = locator.get_all_symbol_pins(schematic_path, source_ref) or {} - tgt_pins = locator.get_all_symbol_pins(schematic_path, target_ref) or {} - - if not src_pins: - return {"connected": [], "failed": [f"No pins found on {source_ref}"]} - if not tgt_pins: - return {"connected": [], "failed": [f"No pins found on {target_ref}"]} - - connected = [] - failed = [] - - for pin_num in sorted(src_pins.keys(), key=lambda x: int(x) if x.isdigit() else 0): - try: - net_name = ( - f"{net_prefix}_{int(pin_num) + pin_offset}" - if pin_num.isdigit() - else f"{net_prefix}_{pin_num}" - ) - - res_src = ConnectionManager.connect_to_net( - schematic_path, source_ref, pin_num, net_name - ) - if not res_src.get("success"): - failed.append(f"{source_ref}/{pin_num}") - continue - - if pin_num in tgt_pins: - res_tgt = ConnectionManager.connect_to_net( - schematic_path, target_ref, pin_num, net_name - ) - if not res_tgt.get("success"): - failed.append(f"{target_ref}/{pin_num}") - continue - else: - failed.append(f"{target_ref}/{pin_num} (pin not found)") - continue - - connected.append(f"{source_ref}/{pin_num} <-> {target_ref}/{pin_num} [{net_name}]") - except Exception as e: - failed.append(f"{source_ref}/{pin_num}: {e}") - - logger.info(f"connect_passthrough: {len(connected)} connected, {len(failed)} failed") - return {"connected": connected, "failed": failed} - - @staticmethod - def get_net_connections( - schematic: Schematic, net_name: str, schematic_path: Optional[Path] = None - ) -> List[Dict]: - """ - Get all connections for a named net using wire graph analysis - - Args: - schematic: Schematic object - net_name: Name of the net to query - schematic_path: Optional path to schematic file (enables accurate pin matching) - - Returns: - List of connections: [{"component": ref, "pin": pin_name}, ...] - """ - try: - from commands.pin_locator import PinLocator - - connections = [] - tolerance = 0.5 # 0.5mm tolerance for point coincidence (grid spacing consideration) - - def points_coincide(p1: Any, p2: Any) -> bool: - """Check if two points are the same (within tolerance)""" - if not p1 or not p2: - return False - dx = abs(p1[0] - p2[0]) - dy = abs(p1[1] - p2[1]) - return dx < tolerance and dy < tolerance - - # 1. Find all labels with this net name - if not hasattr(schematic, "label"): - logger.warning("Schematic has no labels") - return connections - - net_label_positions = [] - for label in schematic.label: - if hasattr(label, "value") and label.value == net_name: - if hasattr(label, "at") and hasattr(label.at, "value"): - pos = label.at.value - net_label_positions.append([float(pos[0]), float(pos[1])]) - - if not net_label_positions: - logger.info(f"No labels found for net '{net_name}'") - return connections - - logger.debug(f"Found {len(net_label_positions)} labels for net '{net_name}'") - - # 2. Find all wires connected to these label positions - if not hasattr(schematic, "wire"): - logger.warning("Schematic has no wires") - return connections - - connected_wire_points = set() - for wire in schematic.wire: - if hasattr(wire, "pts") and hasattr(wire.pts, "xy"): - # Get all points in this wire (polyline) - wire_points = [] - for point in wire.pts.xy: - if hasattr(point, "value"): - wire_points.append([float(point.value[0]), float(point.value[1])]) - - # Check if any wire point touches a label - wire_connected = False - for wire_pt in wire_points: - for label_pt in net_label_positions: - if points_coincide(wire_pt, label_pt): - wire_connected = True - break - if wire_connected: - break - - # If this wire is connected to the net, add all its points - if wire_connected: - for pt in wire_points: - connected_wire_points.add((pt[0], pt[1])) - - if not connected_wire_points: - logger.debug(f"No wires connected to net '{net_name}' labels") - return connections - - logger.debug( - f"Found {len(connected_wire_points)} wire connection points for net '{net_name}'" - ) - - # 3. Find component pins at wire endpoints - if not hasattr(schematic, "symbol"): - logger.warning("Schematic has no symbols") - return connections - - # Create pin locator for accurate pin matching (if schematic_path available) - locator = None - if schematic_path and WIRE_MANAGER_AVAILABLE: - locator = PinLocator() - - for symbol in schematic.symbol: - # Skip template symbols - if not hasattr(symbol.property, "Reference"): - continue - - ref = symbol.property.Reference.value - if ref.startswith("_TEMPLATE"): - continue - - # Get lib_id for pin location lookup - lib_id = symbol.lib_id.value if hasattr(symbol, "lib_id") else None - if not lib_id: - continue - - # If we have PinLocator and schematic_path, do accurate pin matching - if locator and schematic_path: - try: - # Get all pins for this symbol - pins = locator.get_symbol_pins(schematic_path, lib_id) - if not pins: - continue - - # Check each pin - for pin_num, pin_data in pins.items(): - # Get pin location - pin_loc = locator.get_pin_location(schematic_path, ref, pin_num) - if not pin_loc: - continue - - # Check if pin coincides with any wire point - for wire_pt_tup in connected_wire_points: - if points_coincide(pin_loc, list(wire_pt_tup)): - connections.append({"component": ref, "pin": pin_num}) - break # Pin found, no need to check more wire points - - except Exception as e: - logger.warning(f"Error matching pins for {ref}: {e}") - # Fall back to proximity matching - pass - - # Fallback: proximity-based matching if no PinLocator - if not locator or not schematic_path: - symbol_pos = symbol.at.value if hasattr(symbol, "at") else None - if not symbol_pos: - continue - - symbol_x = float(symbol_pos[0]) - symbol_y = float(symbol_pos[1]) - - # Check if symbol is near any wire point (within 10mm) - for wire_pt_tup in connected_wire_points: - dist = ( - (symbol_x - wire_pt_tup[0]) ** 2 + (symbol_y - wire_pt_tup[1]) ** 2 - ) ** 0.5 - if dist < 10.0: # 10mm proximity threshold - connections.append({"component": ref, "pin": "unknown"}) - break # Only add once per component - - logger.info(f"Found {len(connections)} connections for net '{net_name}'") - return connections - - except Exception as e: - logger.error(f"Error getting net connections: {e}") - import traceback - - logger.error(traceback.format_exc()) - return [] - - @staticmethod - def generate_netlist( - schematic: Schematic, schematic_path: Optional[Path] = None - ) -> Dict[str, Any]: - """ - Generate a netlist from the schematic - - Args: - schematic: Schematic object - schematic_path: Optional path to schematic file (enables accurate pin matching - via PinLocator; without it, only one connection per component is found) - - Returns: - Dictionary with net information: - { - "nets": [ - { - "name": "VCC", - "connections": [ - {"component": "R1", "pin": "1"}, - {"component": "C1", "pin": "1"} - ] - }, - ... - ], - "components": [ - {"reference": "R1", "value": "10k", "footprint": "..."}, - ... - ] - } - """ - try: - netlist = {"nets": [], "components": []} - - # Gather all components - if hasattr(schematic, "symbol"): - for symbol in schematic.symbol: - component_info = { - "reference": symbol.property.Reference.value, - "value": ( - symbol.property.Value.value if hasattr(symbol.property, "Value") else "" - ), - "footprint": ( - symbol.property.Footprint.value - if hasattr(symbol.property, "Footprint") - else "" - ), - } - netlist["components"].append(component_info) - - # Gather all nets from labels - if hasattr(schematic, "label"): - net_names = set() - for label in schematic.label: - if hasattr(label, "value"): - net_names.add(label.value) - - # For each net, get connections - for net_name in net_names: - connections = ConnectionManager.get_net_connections( - schematic, net_name, schematic_path - ) - if connections: - netlist["nets"].append({"name": net_name, "connections": connections}) - - logger.info( - f"Generated netlist with {len(netlist['nets'])} nets and {len(netlist['components'])} components" - ) - return netlist - - except Exception as e: - logger.error(f"Error generating netlist: {e}") - return {"nets": [], "components": []} +import logging +import os +from pathlib import Path +from typing import Any, Dict, List, Optional + +from skip import Schematic + +logger = logging.getLogger(__name__) + +# Import new wire and pin managers +try: + from commands.pin_locator import PinLocator + from commands.wire_manager import WireManager + + 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) -> Any: + """Get or create pin locator instance""" + if cls._pin_locator is None and WIRE_MANAGER_AVAILABLE: + cls._pin_locator = PinLocator() + return cls._pin_locator + + @staticmethod + def add_net_label(schematic: Schematic, net_name: str, position: list) -> Any: + """ + Add a net label to the schematic + + Args: + schematic: Schematic object + net_name: Name of the net (e.g., "VCC", "GND", "SIGNAL_1") + position: [x, y] coordinates for the label + + Returns: + Label object or None on error + """ + try: + if not hasattr(schematic, "label"): + logger.error("Schematic does not have label collection") + return None + + label = schematic.label.append(text=net_name, at={"x": position[0], "y": position[1]}) + logger.info(f"Added net label '{net_name}' at {position}") + return label + except Exception as e: + logger.error(f"Error adding net label: {e}") + return None + + @staticmethod + def connect_to_net( + schematic_path: Path, component_ref: str, pin_name: str, net_name: str + ) -> Dict[str, Any]: + """ + Connect a component pin to a named net using a wire stub and label. + + Args: + schematic_path: Path to .kicad_sch file + component_ref: Reference designator (e.g., "U1", "U1_") + pin_name: Pin name/number + net_name: Name of the net to connect to (e.g., "VCC", "GND", "SIGNAL_1") + + Returns: + Dict with keys: + success – bool + pin_location – [x, y] exact pin endpoint used (present on success) + label_location – [x, y] where the net label was placed (present on success) + wire_stub – [[x1,y1],[x2,y2]] the wire segment added (present on success) + message – human-readable status + """ + try: + if not WIRE_MANAGER_AVAILABLE: + logger.error("WireManager/PinLocator not available") + return {"success": False, "message": "WireManager/PinLocator not available"} + + locator = ConnectionManager.get_pin_locator() + if not locator: + logger.error("Pin locator unavailable") + return {"success": False, "message": "Pin locator unavailable"} + + # Get pin location using PinLocator + pin_loc = locator.get_pin_location(schematic_path, component_ref, pin_name) + if not pin_loc: + msg = f"Could not locate pin {component_ref}/{pin_name}" + logger.error(msg) + return {"success": False, "message": msg} + + # Add a small wire stub from the pin (2.54mm = 0.1 inch, standard grid spacing) + # Stub direction follows the pin's outward angle from the PinLocator + try: + pin_angle_deg = locator.get_pin_angle(schematic_path, component_ref, pin_name) or 0 + except Exception as e: + logger.warning( + f"Could not get pin angle for {component_ref}/{pin_name}, defaulting to 0: {e}" + ) + pin_angle_deg = 0 + import math as _math + + angle_rad = _math.radians(pin_angle_deg) + stub_end = [ + round(pin_loc[0] + 2.54 * _math.cos(angle_rad), 4), + round(pin_loc[1] - 2.54 * _math.sin(angle_rad), 4), + ] + + # Create wire stub using WireManager + wire_success = WireManager.add_wire(schematic_path, pin_loc, stub_end) + if not wire_success: + msg = "Failed to create wire stub for net connection" + logger.error(msg) + return {"success": False, "message": msg} + + # Add label at the end of the stub using WireManager + label_success = WireManager.add_label( + schematic_path, net_name, stub_end, label_type="label" + ) + if not label_success: + msg = f"Failed to add net label '{net_name}'" + logger.error(msg) + return {"success": False, "message": msg} + + logger.info(f"Connected {component_ref}/{pin_name} to net '{net_name}'") + return { + "success": True, + "message": f"Connected {component_ref}/{pin_name} to net '{net_name}'", + "pin_location": pin_loc, + "label_location": stub_end, + "wire_stub": [pin_loc, stub_end], + } + + except Exception as e: + logger.error(f"Error connecting to net: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + @staticmethod + def connect_passthrough( + schematic_path: Path, + source_ref: str, + target_ref: str, + net_prefix: str = "PIN", + pin_offset: int = 0, + ) -> Dict[str, List[str]]: + """ + Connect all pins of source_ref to matching pins of target_ref via shared net labels. + Useful for passthrough adapters: J1 pin N <-> J2 pin N on net {net_prefix}_{N}. + + Args: + schematic_path: Path to .kicad_sch file + source_ref: Reference of the first connector (e.g., "J1") + target_ref: Reference of the second connector (e.g., "J2") + net_prefix: Prefix for generated net names (default: "PIN" -> PIN_1, PIN_2, ...) + pin_offset: Add this value to the pin number when building the net name (default 0) + + Returns: + dict with 'connected' list and 'failed' list + """ + if not WIRE_MANAGER_AVAILABLE: + logger.error("WireManager/PinLocator not available") + return {"connected": [], "failed": ["WireManager unavailable"]} + + locator = ConnectionManager.get_pin_locator() + if not locator: + return {"connected": [], "failed": ["PinLocator unavailable"]} + + # Get all pins of source and target + src_pins = locator.get_all_symbol_pins(schematic_path, source_ref) or {} + tgt_pins = locator.get_all_symbol_pins(schematic_path, target_ref) or {} + + if not src_pins: + return {"connected": [], "failed": [f"No pins found on {source_ref}"]} + if not tgt_pins: + return {"connected": [], "failed": [f"No pins found on {target_ref}"]} + + connected = [] + failed = [] + + for pin_num in sorted(src_pins.keys(), key=lambda x: int(x) if x.isdigit() else 0): + try: + net_name = ( + f"{net_prefix}_{int(pin_num) + pin_offset}" + if pin_num.isdigit() + else f"{net_prefix}_{pin_num}" + ) + + res_src = ConnectionManager.connect_to_net( + schematic_path, source_ref, pin_num, net_name + ) + if not res_src.get("success"): + failed.append(f"{source_ref}/{pin_num}") + continue + + if pin_num in tgt_pins: + res_tgt = ConnectionManager.connect_to_net( + schematic_path, target_ref, pin_num, net_name + ) + if not res_tgt.get("success"): + failed.append(f"{target_ref}/{pin_num}") + continue + else: + failed.append(f"{target_ref}/{pin_num} (pin not found)") + continue + + connected.append(f"{source_ref}/{pin_num} <-> {target_ref}/{pin_num} [{net_name}]") + except Exception as e: + failed.append(f"{source_ref}/{pin_num}: {e}") + + logger.info(f"connect_passthrough: {len(connected)} connected, {len(failed)} failed") + return {"connected": connected, "failed": failed} + + @staticmethod + def get_net_connections( + schematic: Schematic, net_name: str, schematic_path: Optional[Path] = None + ) -> List[Dict]: + """ + Get all connections for a named net using wire graph analysis + + Args: + schematic: Schematic object + net_name: Name of the net to query + schematic_path: Optional path to schematic file (enables accurate pin matching) + + Returns: + List of connections: [{"component": ref, "pin": pin_name}, ...] + """ + try: + from commands.pin_locator import PinLocator + + connections = [] + tolerance = 0.5 # 0.5mm tolerance for point coincidence (grid spacing consideration) + + def points_coincide(p1: Any, p2: Any) -> bool: + """Check if two points are the same (within tolerance)""" + if not p1 or not p2: + return False + dx = abs(p1[0] - p2[0]) + dy = abs(p1[1] - p2[1]) + return dx < tolerance and dy < tolerance + + # 1. Find all labels with this net name + if not hasattr(schematic, "label"): + logger.warning("Schematic has no labels") + return connections + + net_label_positions = [] + for label in schematic.label: + if hasattr(label, "value") and label.value == net_name: + if hasattr(label, "at") and hasattr(label.at, "value"): + pos = label.at.value + net_label_positions.append([float(pos[0]), float(pos[1])]) + + if not net_label_positions: + logger.info(f"No labels found for net '{net_name}'") + return connections + + logger.debug(f"Found {len(net_label_positions)} labels for net '{net_name}'") + + # 2. Find all wires connected to these label positions. + # A missing wire attribute is fine — all_match_points will still + # include label positions, so label-at-pin connections are detected. + connected_wire_points: set[tuple[float, float]] = set() + if not hasattr(schematic, "wire"): + logger.debug("Schematic has no wires — will match labels to pins directly") + + for wire in (schematic.wire if hasattr(schematic, "wire") else []): + if hasattr(wire, "pts") and hasattr(wire.pts, "xy"): + # Get all points in this wire (polyline) + wire_points = [] + for point in wire.pts.xy: + if hasattr(point, "value"): + wire_points.append([float(point.value[0]), float(point.value[1])]) + + # Check if any wire point touches a label + wire_connected = False + for wire_pt in wire_points: + for label_pt in net_label_positions: + if points_coincide(wire_pt, label_pt): + wire_connected = True + break + if wire_connected: + break + + # If this wire is connected to the net, add all its points + if wire_connected: + for pt in wire_points: + connected_wire_points.add((pt[0], pt[1])) + + # Build match points: union of wire endpoints AND label positions. + # This handles the valid KiCad style where a net label is placed + # directly at a pin endpoint with no wire segment in between. + all_match_points = connected_wire_points | {(p[0], p[1]) for p in net_label_positions} + + if not all_match_points: + logger.debug(f"No connection points found for net '{net_name}'") + return connections + + logger.debug( + f"Found {len(connected_wire_points)} wire points, " + f"{len(net_label_positions)} direct label positions, " + f"{len(all_match_points)} total match points for net '{net_name}'" + ) + + # 3. Find component pins at wire endpoints + if not hasattr(schematic, "symbol"): + logger.warning("Schematic has no symbols") + return connections + + # Create pin locator for accurate pin matching (if schematic_path available) + locator = None + if schematic_path and WIRE_MANAGER_AVAILABLE: + locator = PinLocator() + + for symbol in schematic.symbol: + # Skip template symbols + if not hasattr(symbol.property, "Reference"): + continue + + ref = symbol.property.Reference.value + if ref.startswith("_TEMPLATE"): + continue + + # Get lib_id for pin location lookup + lib_id = symbol.lib_id.value if hasattr(symbol, "lib_id") else None + if not lib_id: + continue + + # If we have PinLocator and schematic_path, do accurate pin matching + if locator and schematic_path: + try: + # Get all pins for this symbol + pins = locator.get_symbol_pins(schematic_path, lib_id) + if not pins: + continue + + # Check each pin + for pin_num, pin_data in pins.items(): + # Get pin location + pin_loc = locator.get_pin_location(schematic_path, ref, pin_num) + if not pin_loc: + continue + + # Check if pin coincides with any match point + for wire_pt_tup in all_match_points: + if points_coincide(pin_loc, list(wire_pt_tup)): + connections.append({"component": ref, "pin": pin_num}) + break # Pin found, no need to check more wire points + + except Exception as e: + logger.warning(f"Error matching pins for {ref}: {e}") + # Fall back to proximity matching + pass + + # Fallback: proximity-based matching if no PinLocator + if not locator or not schematic_path: + symbol_pos = symbol.at.value if hasattr(symbol, "at") else None + if not symbol_pos: + continue + + symbol_x = float(symbol_pos[0]) + symbol_y = float(symbol_pos[1]) + + # Check if symbol is near any match point (within 10mm) + for wire_pt_tup in all_match_points: + dist = ( + (symbol_x - wire_pt_tup[0]) ** 2 + (symbol_y - wire_pt_tup[1]) ** 2 + ) ** 0.5 + if dist < 10.0: # 10mm proximity threshold + connections.append({"component": ref, "pin": "unknown"}) + break # Only add once per component + + logger.info(f"Found {len(connections)} connections for net '{net_name}'") + return connections + + except Exception as e: + logger.error(f"Error getting net connections: {e}") + import traceback + + logger.error(traceback.format_exc()) + return [] + + @staticmethod + def generate_netlist( + schematic: Schematic, schematic_path: Optional[Path] = None + ) -> Dict[str, Any]: + """ + Generate a netlist from the schematic + + Args: + schematic: Schematic object + schematic_path: Optional path to schematic file (enables accurate pin matching + via PinLocator; without it, only one connection per component is found) + + Returns: + Dictionary with net information: + { + "nets": [ + { + "name": "VCC", + "connections": [ + {"component": "R1", "pin": "1"}, + {"component": "C1", "pin": "1"} + ] + }, + ... + ], + "components": [ + {"reference": "R1", "value": "10k", "footprint": "..."}, + ... + ] + } + """ + try: + netlist = {"nets": [], "components": []} + + # Gather all components + if hasattr(schematic, "symbol"): + for symbol in schematic.symbol: + component_info = { + "reference": symbol.property.Reference.value, + "value": ( + symbol.property.Value.value if hasattr(symbol.property, "Value") else "" + ), + "footprint": ( + symbol.property.Footprint.value + if hasattr(symbol.property, "Footprint") + else "" + ), + } + netlist["components"].append(component_info) + + # Gather all nets from labels + if hasattr(schematic, "label"): + net_names = set() + for label in schematic.label: + if hasattr(label, "value"): + net_names.add(label.value) + + # For each net, get connections + for net_name in net_names: + connections = ConnectionManager.get_net_connections( + schematic, net_name, schematic_path + ) + if connections: + netlist["nets"].append({"name": net_name, "connections": connections}) + + logger.info( + f"Generated netlist with {len(netlist['nets'])} nets and {len(netlist['components'])} components" + ) + return netlist + + except Exception as e: + logger.error(f"Error generating netlist: {e}") + return {"nets": [], "components": []} diff --git a/tests/test_label_at_pin_net_connections.py b/tests/test_label_at_pin_net_connections.py new file mode 100644 index 0000000..b3d0c02 --- /dev/null +++ b/tests/test_label_at_pin_net_connections.py @@ -0,0 +1,427 @@ +""" +Tests for get_net_connections() — label-at-pin (no wire) fix. + +Before the fix, get_net_connections() built its match-point set exclusively +from wire endpoints. If a net label was placed directly at a pin endpoint +with no wire segment in between (a valid KiCad style), the function returned +early with 0 connections because connected_wire_points was empty. + +The fix builds all_match_points as the union of wire endpoints AND label +positions, so a label placed at a pin endpoint is detected whether or not a +wire exists. + +Covers: + - Label at pin, no wire → pin IS found (core bug fix) + - Label connected via wire → pin IS found (regression: existing behaviour) + - Label with wires, pin elsewhere → no match (regression: no false positives) + - Multiple labels for same net, mixed styles (regression: mixed case) + - No labels for requested net → empty result (edge case) + - Schematic has no wire attribute → still works (edge case) +""" + +import sys +import types +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +# --------------------------------------------------------------------------- +# Path setup +# --------------------------------------------------------------------------- + +PYTHON_DIR = Path(__file__).parent.parent / "python" +sys.path.insert(0, str(PYTHON_DIR)) + + +# --------------------------------------------------------------------------- +# Mock helpers (mirrors pattern used in test_net_connectivity.py) +# --------------------------------------------------------------------------- + + +def _make_point(x: float, y: float) -> MagicMock: + pt = MagicMock() + pt.value = [x, y] + return pt + + +def _make_wire(x1: float, y1: float, x2: float, y2: float) -> MagicMock: + wire = MagicMock() + wire.pts = MagicMock() + wire.pts.xy = [_make_point(x1, y1), _make_point(x2, y2)] + return wire + + +def _make_label(name: str, x: float, y: float) -> MagicMock: + label = MagicMock() + label.value = name + label.at = MagicMock() + label.at.value = [x, y, 0] + return label + + +def _make_symbol(ref: str) -> MagicMock: + sym = MagicMock() + sym.property = MagicMock() + sym.property.Reference = MagicMock() + sym.property.Reference.value = ref + sym.lib_id = MagicMock() + sym.lib_id.value = f"Device:{ref}" + return sym + + +def _make_schematic( + labels: list[Any], + wires: list[Any], + symbols: list[Any], +) -> MagicMock: + sch = MagicMock() + sch.label = labels + sch.wire = wires + sch.symbol = symbols + return sch + + +# --------------------------------------------------------------------------- +# Shared import helper +# --------------------------------------------------------------------------- + + +def _get_connection_manager() -> Any: + for mod in ["pcbnew", "skip"]: + sys.modules.setdefault(mod, types.ModuleType(mod)) + from commands.connection_schematic import ConnectionManager + + return ConnectionManager + + +# --------------------------------------------------------------------------- +# TestLabelAtPinNoWire — the core bug fix +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestLabelAtPinNoWire: + """Label placed directly at a pin endpoint, no wire segment — must be detected.""" + + def test_label_at_pin_no_wire_finds_connection(self) -> None: + """Primary regression: label at (5, 3), pin at (5, 3), no wire → connection found.""" + ConnectionManager = _get_connection_manager() + + label = _make_label("VCC", 5.0, 3.0) + symbol = _make_symbol("U1") + sch = _make_schematic(labels=[label], wires=[], symbols=[symbol]) + + with ( + patch( + "commands.pin_locator.PinLocator.get_symbol_pins", + return_value={"1": {}}, + ), + patch( + "commands.pin_locator.PinLocator.get_pin_location", + return_value=[5.0, 3.0], # pin exactly at label position + ), + ): + result = ConnectionManager.get_net_connections( + sch, + "VCC", + schematic_path=Path("/fake/test.kicad_sch"), + ) + + assert len(result) == 1 + assert result[0]["component"] == "U1" + assert result[0]["pin"] == "1" + + def test_label_at_pin_no_wire_multiple_pins(self) -> None: + """Two pins on the same net label, no wires — both detected.""" + ConnectionManager = _get_connection_manager() + + label = _make_label("GND", 0.0, 0.0) + sym_r1 = _make_symbol("R1") + sym_c1 = _make_symbol("C1") + sch = _make_schematic(labels=[label], wires=[], symbols=[sym_r1, sym_c1]) + + def fake_get_pins(sch_path: Any, lib_id: str) -> dict: # type: ignore[return] + return {"2": {}} + + def fake_get_pin_loc(sch_path: Any, ref: str, pin_num: str) -> list: # type: ignore[return] + # Both R1 pin 2 and C1 pin 2 sit exactly at the label + return [0.0, 0.0] + + with ( + patch("commands.pin_locator.PinLocator.get_symbol_pins", side_effect=fake_get_pins), + patch("commands.pin_locator.PinLocator.get_pin_location", side_effect=fake_get_pin_loc), + ): + result = ConnectionManager.get_net_connections( + sch, + "GND", + schematic_path=Path("/fake/test.kicad_sch"), + ) + + refs = {r["component"] for r in result} + assert "R1" in refs + assert "C1" in refs + + def test_label_at_pin_within_tolerance(self) -> None: + """Label at (5.0, 3.0), pin at (5.3, 3.0) — within 0.5 mm tolerance → found.""" + ConnectionManager = _get_connection_manager() + + label = _make_label("NET_A", 5.0, 3.0) + symbol = _make_symbol("D1") + sch = _make_schematic(labels=[label], wires=[], symbols=[symbol]) + + with ( + patch( + "commands.pin_locator.PinLocator.get_symbol_pins", + return_value={"A": {}}, + ), + patch( + "commands.pin_locator.PinLocator.get_pin_location", + return_value=[5.3, 3.0], # within 0.5 mm + ), + ): + result = ConnectionManager.get_net_connections( + sch, + "NET_A", + schematic_path=Path("/fake/test.kicad_sch"), + ) + + assert len(result) == 1 + + def test_label_at_pin_outside_tolerance_no_match(self) -> None: + """Label at (5.0, 3.0), pin at (6.0, 3.0) — outside tolerance → not found.""" + ConnectionManager = _get_connection_manager() + + label = _make_label("NET_B", 5.0, 3.0) + symbol = _make_symbol("Q1") + sch = _make_schematic(labels=[label], wires=[], symbols=[symbol]) + + with ( + patch( + "commands.pin_locator.PinLocator.get_symbol_pins", + return_value={"B": {}}, + ), + patch( + "commands.pin_locator.PinLocator.get_pin_location", + return_value=[6.0, 3.0], # 1 mm away — outside 0.5 mm tolerance + ), + ): + result = ConnectionManager.get_net_connections( + sch, + "NET_B", + schematic_path=Path("/fake/test.kicad_sch"), + ) + + assert len(result) == 0 + + +# --------------------------------------------------------------------------- +# TestLabelViaWire — regression: existing wire-based behaviour preserved +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestLabelViaWire: + """Wire-connected nets must still work after the fix (no regression).""" + + def test_label_connected_via_wire_finds_pin(self) -> None: + """Label at (0,0) → wire to (5,0) → pin at (5,0) → connection found.""" + ConnectionManager = _get_connection_manager() + + label = _make_label("SCL", 0.0, 0.0) + wire = _make_wire(0.0, 0.0, 5.0, 0.0) + symbol = _make_symbol("U2") + sch = _make_schematic(labels=[label], wires=[wire], symbols=[symbol]) + + with ( + patch( + "commands.pin_locator.PinLocator.get_symbol_pins", + return_value={"3": {}}, + ), + patch( + "commands.pin_locator.PinLocator.get_pin_location", + return_value=[5.0, 0.0], + ), + ): + result = ConnectionManager.get_net_connections( + sch, + "SCL", + schematic_path=Path("/fake/test.kicad_sch"), + ) + + assert len(result) == 1 + assert result[0]["component"] == "U2" + assert result[0]["pin"] == "3" + + def test_wire_connected_pin_elsewhere_not_matched(self) -> None: + """Pin at (99, 99) with wire only reaching (5, 0) — pin must NOT be returned.""" + ConnectionManager = _get_connection_manager() + + label = _make_label("SDA", 0.0, 0.0) + wire = _make_wire(0.0, 0.0, 5.0, 0.0) + symbol = _make_symbol("U3") + sch = _make_schematic(labels=[label], wires=[wire], symbols=[symbol]) + + with ( + patch( + "commands.pin_locator.PinLocator.get_symbol_pins", + return_value={"4": {}}, + ), + patch( + "commands.pin_locator.PinLocator.get_pin_location", + return_value=[99.0, 99.0], + ), + ): + result = ConnectionManager.get_net_connections( + sch, + "SDA", + schematic_path=Path("/fake/test.kicad_sch"), + ) + + assert len(result) == 0 + + +# --------------------------------------------------------------------------- +# TestMixedStyles — both styles on the same net +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestMixedStyles: + """One label wired, another label direct-at-pin — both connections found.""" + + def test_mixed_wired_and_direct_label(self) -> None: + """ + Net 'MOSI' has two labels: + - Label A at (0,0) with wire to pin at (5,0) [wired style] + - Label B at (10,3) directly on pin at (10,3) [direct style] + Both should be found. + """ + ConnectionManager = _get_connection_manager() + + label_a = _make_label("MOSI", 0.0, 0.0) + label_b = _make_label("MOSI", 10.0, 3.0) + wire = _make_wire(0.0, 0.0, 5.0, 0.0) + sym_wired = _make_symbol("U4") + sym_direct = _make_symbol("U5") + + sch = _make_schematic( + labels=[label_a, label_b], + wires=[wire], + symbols=[sym_wired, sym_direct], + ) + + # U4 pin at wire endpoint, U5 pin at direct label position + def fake_get_pins(sch_path: Any, lib_id: str) -> dict: # type: ignore[return] + return {"1": {}} + + def fake_get_pin_loc(sch_path: Any, ref: str, pin_num: str) -> list: # type: ignore[return] + if ref == "U4": + return [5.0, 0.0] + return [10.0, 3.0] + + with ( + patch("commands.pin_locator.PinLocator.get_symbol_pins", side_effect=fake_get_pins), + patch("commands.pin_locator.PinLocator.get_pin_location", side_effect=fake_get_pin_loc), + ): + result = ConnectionManager.get_net_connections( + sch, + "MOSI", + schematic_path=Path("/fake/test.kicad_sch"), + ) + + refs = {r["component"] for r in result} + assert "U4" in refs, "wired-style pin not found" + assert "U5" in refs, "direct-label-at-pin not found" + + +# --------------------------------------------------------------------------- +# TestEdgeCases +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestEdgeCases: + """Boundary conditions that should not crash or return false positives.""" + + def test_unknown_net_returns_empty(self) -> None: + """Requesting a net name that doesn't exist returns [].""" + ConnectionManager = _get_connection_manager() + + label = _make_label("VCC", 0.0, 0.0) + sch = _make_schematic(labels=[label], wires=[], symbols=[]) + + result = ConnectionManager.get_net_connections(sch, "DOES_NOT_EXIST") + assert result == [] + + def test_no_labels_on_schematic_returns_empty(self) -> None: + """Schematic with no label attribute returns [] gracefully.""" + ConnectionManager = _get_connection_manager() + + sch = MagicMock() + del sch.label # simulate a schematic with no labels + + result = ConnectionManager.get_net_connections(sch, "VCC") + assert result == [] + + def test_no_wire_attribute_still_checks_label_positions(self) -> None: + """Schematic with no wire attribute must still match label-at-pin.""" + ConnectionManager = _get_connection_manager() + + label = _make_label("RST", 7.0, 2.0) + symbol = _make_symbol("IC1") + + sch = MagicMock() + sch.label = [label] + del sch.wire # no wire attribute at all + sch.symbol = [symbol] + + with ( + patch( + "commands.pin_locator.PinLocator.get_symbol_pins", + return_value={"RST": {}}, + ), + patch( + "commands.pin_locator.PinLocator.get_pin_location", + return_value=[7.0, 2.0], + ), + ): + result = ConnectionManager.get_net_connections( + sch, + "RST", + schematic_path=Path("/fake/test.kicad_sch"), + ) + + assert len(result) == 1 + assert result[0]["component"] == "IC1" + + def test_template_symbols_skipped(self) -> None: + """_TEMPLATE_ reference symbols must not appear in results.""" + ConnectionManager = _get_connection_manager() + + label = _make_label("PWR", 0.0, 0.0) + template_sym = _make_symbol("_TEMPLATE_PWR") + real_sym = _make_symbol("U6") + + sch = _make_schematic(labels=[label], wires=[], symbols=[template_sym, real_sym]) + + def fake_get_pins(sch_path: Any, lib_id: str) -> dict: # type: ignore[return] + return {"1": {}} + + def fake_get_pin_loc(sch_path: Any, ref: str, pin_num: str) -> list: # type: ignore[return] + return [0.0, 0.0] + + with ( + patch("commands.pin_locator.PinLocator.get_symbol_pins", side_effect=fake_get_pins), + patch("commands.pin_locator.PinLocator.get_pin_location", side_effect=fake_get_pin_loc), + ): + result = ConnectionManager.get_net_connections( + sch, + "PWR", + schematic_path=Path("/fake/test.kicad_sch"), + ) + + refs = {r["component"] for r in result} + assert "_TEMPLATE_PWR" not in refs, "_TEMPLATE_ symbol must be skipped" + assert "U6" in refs