Merge pull request #102 from ffindog/fix/netlist-label-at-pin

fix: resolve nets when labels are placed directly at pin endpoints
This commit is contained in:
Eugene Mikhantyev
2026-04-18 14:37:46 +01:00
committed by GitHub
2 changed files with 890 additions and 455 deletions

View File

@@ -1,455 +1,463 @@
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from skip import Schematic from skip import Schematic
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Import new wire and pin managers # Import new wire and pin managers
try: try:
from commands.pin_locator import PinLocator from commands.pin_locator import PinLocator
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
WIRE_MANAGER_AVAILABLE = True WIRE_MANAGER_AVAILABLE = True
except ImportError: except ImportError:
logger.warning("WireManager/PinLocator not available") logger.warning("WireManager/PinLocator not available")
WIRE_MANAGER_AVAILABLE = False WIRE_MANAGER_AVAILABLE = False
class ConnectionManager: class ConnectionManager:
"""Manage connections between components in schematics""" """Manage connections between components in schematics"""
# Initialize pin locator (class variable, shared across instances) # Initialize pin locator (class variable, shared across instances)
_pin_locator = None _pin_locator = None
@classmethod @classmethod
def get_pin_locator(cls) -> Any: def get_pin_locator(cls) -> Any:
"""Get or create pin locator instance""" """Get or create pin locator instance"""
if cls._pin_locator is None and WIRE_MANAGER_AVAILABLE: if cls._pin_locator is None and WIRE_MANAGER_AVAILABLE:
cls._pin_locator = PinLocator() cls._pin_locator = PinLocator()
return cls._pin_locator return cls._pin_locator
@staticmethod @staticmethod
def add_net_label(schematic: Schematic, net_name: str, position: list) -> Any: def add_net_label(schematic: Schematic, net_name: str, position: list) -> Any:
""" """
Add a net label to the schematic Add a net label to the schematic
Args: Args:
schematic: Schematic object schematic: Schematic object
net_name: Name of the net (e.g., "VCC", "GND", "SIGNAL_1") net_name: Name of the net (e.g., "VCC", "GND", "SIGNAL_1")
position: [x, y] coordinates for the label position: [x, y] coordinates for the label
Returns: Returns:
Label object or None on error Label object or None on error
""" """
try: try:
if not hasattr(schematic, "label"): if not hasattr(schematic, "label"):
logger.error("Schematic does not have label collection") logger.error("Schematic does not have label collection")
return None return None
label = schematic.label.append(text=net_name, at={"x": position[0], "y": position[1]}) label = schematic.label.append(text=net_name, at={"x": position[0], "y": position[1]})
logger.info(f"Added net label '{net_name}' at {position}") logger.info(f"Added net label '{net_name}' at {position}")
return label return label
except Exception as e: except Exception as e:
logger.error(f"Error adding net label: {e}") logger.error(f"Error adding net label: {e}")
return None return None
@staticmethod @staticmethod
def connect_to_net( def connect_to_net(
schematic_path: Path, component_ref: str, pin_name: str, net_name: str schematic_path: Path, component_ref: str, pin_name: str, net_name: str
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Connect a component pin to a named net using a wire stub and label. Connect a component pin to a named net using a wire stub and label.
Args: Args:
schematic_path: Path to .kicad_sch file schematic_path: Path to .kicad_sch file
component_ref: Reference designator (e.g., "U1", "U1_") component_ref: Reference designator (e.g., "U1", "U1_")
pin_name: Pin name/number pin_name: Pin name/number
net_name: Name of the net to connect to (e.g., "VCC", "GND", "SIGNAL_1") net_name: Name of the net to connect to (e.g., "VCC", "GND", "SIGNAL_1")
Returns: Returns:
Dict with keys: Dict with keys:
success bool success bool
pin_location [x, y] exact pin endpoint used (present on success) pin_location [x, y] exact pin endpoint used (present on success)
label_location [x, y] where the net label was placed (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) wire_stub [[x1,y1],[x2,y2]] the wire segment added (present on success)
message human-readable status message human-readable status
""" """
try: try:
if not WIRE_MANAGER_AVAILABLE: if not WIRE_MANAGER_AVAILABLE:
logger.error("WireManager/PinLocator not available") logger.error("WireManager/PinLocator not available")
return {"success": False, "message": "WireManager/PinLocator not available"} return {"success": False, "message": "WireManager/PinLocator not available"}
locator = ConnectionManager.get_pin_locator() locator = ConnectionManager.get_pin_locator()
if not locator: if not locator:
logger.error("Pin locator unavailable") logger.error("Pin locator unavailable")
return {"success": False, "message": "Pin locator unavailable"} return {"success": False, "message": "Pin locator unavailable"}
# Get pin location using PinLocator # Get pin location using PinLocator
pin_loc = locator.get_pin_location(schematic_path, component_ref, pin_name) pin_loc = locator.get_pin_location(schematic_path, component_ref, pin_name)
if not pin_loc: if not pin_loc:
msg = f"Could not locate pin {component_ref}/{pin_name}" msg = f"Could not locate pin {component_ref}/{pin_name}"
logger.error(msg) logger.error(msg)
return {"success": False, "message": msg} return {"success": False, "message": msg}
# Add a small wire stub from the pin (2.54mm = 0.1 inch, standard grid spacing) # 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 # Stub direction follows the pin's outward angle from the PinLocator
try: 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 as e: except Exception as e:
logger.warning( logger.warning(
f"Could not get pin angle for {component_ref}/{pin_name}, defaulting to 0: {e}" f"Could not get pin angle for {component_ref}/{pin_name}, defaulting to 0: {e}"
) )
pin_angle_deg = 0 pin_angle_deg = 0
import math as _math import math as _math
angle_rad = _math.radians(pin_angle_deg) angle_rad = _math.radians(pin_angle_deg)
stub_end = [ stub_end = [
round(pin_loc[0] + 2.54 * _math.cos(angle_rad), 4), round(pin_loc[0] + 2.54 * _math.cos(angle_rad), 4),
round(pin_loc[1] - 2.54 * _math.sin(angle_rad), 4), round(pin_loc[1] - 2.54 * _math.sin(angle_rad), 4),
] ]
# Create wire stub using WireManager # Create wire stub using WireManager
wire_success = WireManager.add_wire(schematic_path, pin_loc, stub_end) wire_success = WireManager.add_wire(schematic_path, pin_loc, stub_end)
if not wire_success: if not wire_success:
msg = "Failed to create wire stub for net connection" msg = "Failed to create wire stub for net connection"
logger.error(msg) logger.error(msg)
return {"success": False, "message": msg} return {"success": False, "message": msg}
# Add label at the end of the stub using WireManager # Add label at the end of the stub using WireManager
label_success = WireManager.add_label( label_success = WireManager.add_label(
schematic_path, net_name, stub_end, label_type="label" schematic_path, net_name, stub_end, label_type="label"
) )
if not label_success: if not label_success:
msg = f"Failed to add net label '{net_name}'" msg = f"Failed to add net label '{net_name}'"
logger.error(msg) logger.error(msg)
return {"success": False, "message": msg} return {"success": False, "message": msg}
logger.info(f"Connected {component_ref}/{pin_name} to net '{net_name}'") logger.info(f"Connected {component_ref}/{pin_name} to net '{net_name}'")
return { return {
"success": True, "success": True,
"message": f"Connected {component_ref}/{pin_name} to net '{net_name}'", "message": f"Connected {component_ref}/{pin_name} to net '{net_name}'",
"pin_location": pin_loc, "pin_location": pin_loc,
"label_location": stub_end, "label_location": stub_end,
"wire_stub": [pin_loc, stub_end], "wire_stub": [pin_loc, stub_end],
} }
except Exception as e: except Exception as e:
logger.error(f"Error connecting to net: {e}") logger.error(f"Error connecting to net: {e}")
import traceback import traceback
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
@staticmethod @staticmethod
def connect_passthrough( def connect_passthrough(
schematic_path: Path, schematic_path: Path,
source_ref: str, source_ref: str,
target_ref: str, target_ref: str,
net_prefix: str = "PIN", net_prefix: str = "PIN",
pin_offset: int = 0, pin_offset: int = 0,
) -> Dict[str, List[str]]: ) -> Dict[str, List[str]]:
""" """
Connect all pins of source_ref to matching pins of target_ref via shared net labels. 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}. Useful for passthrough adapters: J1 pin N <-> J2 pin N on net {net_prefix}_{N}.
Args: Args:
schematic_path: Path to .kicad_sch file schematic_path: Path to .kicad_sch file
source_ref: Reference of the first connector (e.g., "J1") source_ref: Reference of the first connector (e.g., "J1")
target_ref: Reference of the second connector (e.g., "J2") target_ref: Reference of the second connector (e.g., "J2")
net_prefix: Prefix for generated net names (default: "PIN" -> PIN_1, PIN_2, ...) 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) pin_offset: Add this value to the pin number when building the net name (default 0)
Returns: Returns:
dict with 'connected' list and 'failed' list dict with 'connected' list and 'failed' list
""" """
if not WIRE_MANAGER_AVAILABLE: if not WIRE_MANAGER_AVAILABLE:
logger.error("WireManager/PinLocator not available") logger.error("WireManager/PinLocator not available")
return {"connected": [], "failed": ["WireManager unavailable"]} return {"connected": [], "failed": ["WireManager unavailable"]}
locator = ConnectionManager.get_pin_locator() locator = ConnectionManager.get_pin_locator()
if not locator: if not locator:
return {"connected": [], "failed": ["PinLocator unavailable"]} return {"connected": [], "failed": ["PinLocator unavailable"]}
# Get all pins of source and target # Get all pins of source and target
src_pins = locator.get_all_symbol_pins(schematic_path, source_ref) or {} src_pins = locator.get_all_symbol_pins(schematic_path, source_ref) or {}
tgt_pins = locator.get_all_symbol_pins(schematic_path, target_ref) or {} tgt_pins = locator.get_all_symbol_pins(schematic_path, target_ref) or {}
if not src_pins: if not src_pins:
return {"connected": [], "failed": [f"No pins found on {source_ref}"]} return {"connected": [], "failed": [f"No pins found on {source_ref}"]}
if not tgt_pins: if not tgt_pins:
return {"connected": [], "failed": [f"No pins found on {target_ref}"]} return {"connected": [], "failed": [f"No pins found on {target_ref}"]}
connected = [] connected = []
failed = [] 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: try:
net_name = ( net_name = (
f"{net_prefix}_{int(pin_num) + pin_offset}" f"{net_prefix}_{int(pin_num) + pin_offset}"
if pin_num.isdigit() if pin_num.isdigit()
else f"{net_prefix}_{pin_num}" else f"{net_prefix}_{pin_num}"
) )
res_src = ConnectionManager.connect_to_net( res_src = ConnectionManager.connect_to_net(
schematic_path, source_ref, pin_num, net_name schematic_path, source_ref, pin_num, net_name
) )
if not res_src.get("success"): if not res_src.get("success"):
failed.append(f"{source_ref}/{pin_num}") failed.append(f"{source_ref}/{pin_num}")
continue continue
if pin_num in tgt_pins: if pin_num in tgt_pins:
res_tgt = ConnectionManager.connect_to_net( res_tgt = ConnectionManager.connect_to_net(
schematic_path, target_ref, pin_num, net_name schematic_path, target_ref, pin_num, net_name
) )
if not res_tgt.get("success"): if not res_tgt.get("success"):
failed.append(f"{target_ref}/{pin_num}") failed.append(f"{target_ref}/{pin_num}")
continue continue
else: else:
failed.append(f"{target_ref}/{pin_num} (pin not found)") failed.append(f"{target_ref}/{pin_num} (pin not found)")
continue 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: except Exception as e:
failed.append(f"{source_ref}/{pin_num}: {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} return {"connected": connected, "failed": failed}
@staticmethod @staticmethod
def get_net_connections( def get_net_connections(
schematic: Schematic, net_name: str, schematic_path: Optional[Path] = None schematic: Schematic, net_name: str, schematic_path: Optional[Path] = None
) -> List[Dict]: ) -> List[Dict]:
""" """
Get all connections for a named net using wire graph analysis Get all connections for a named net using wire graph analysis
Args: Args:
schematic: Schematic object schematic: Schematic object
net_name: Name of the net to query net_name: Name of the net to query
schematic_path: Optional path to schematic file (enables accurate pin matching) schematic_path: Optional path to schematic file (enables accurate pin matching)
Returns: Returns:
List of connections: [{"component": ref, "pin": pin_name}, ...] List of connections: [{"component": ref, "pin": pin_name}, ...]
""" """
try: try:
from commands.pin_locator import PinLocator from commands.pin_locator import PinLocator
connections = [] connections = []
tolerance = 0.5 # 0.5mm tolerance for point coincidence (grid spacing consideration) tolerance = 0.5 # 0.5mm tolerance for point coincidence (grid spacing consideration)
def points_coincide(p1: Any, p2: Any) -> bool: def points_coincide(p1: Any, p2: Any) -> bool:
"""Check if two points are the same (within tolerance)""" """Check if two points are the same (within tolerance)"""
if not p1 or not p2: if not p1 or not p2:
return False return False
dx = abs(p1[0] - p2[0]) dx = abs(p1[0] - p2[0])
dy = abs(p1[1] - p2[1]) dy = abs(p1[1] - p2[1])
return dx < tolerance and dy < tolerance return dx < tolerance and dy < tolerance
# 1. Find all labels with this net name # 1. Find all labels with this net name
if not hasattr(schematic, "label"): if not hasattr(schematic, "label"):
logger.warning("Schematic has no labels") logger.warning("Schematic has no labels")
return connections return connections
net_label_positions = [] net_label_positions = []
for label in schematic.label: for label in schematic.label:
if hasattr(label, "value") and label.value == net_name: if hasattr(label, "value") and label.value == net_name:
if hasattr(label, "at") and hasattr(label.at, "value"): if hasattr(label, "at") and hasattr(label.at, "value"):
pos = label.at.value pos = label.at.value
net_label_positions.append([float(pos[0]), float(pos[1])]) net_label_positions.append([float(pos[0]), float(pos[1])])
if not net_label_positions: if not net_label_positions:
logger.info(f"No labels found for net '{net_name}'") logger.info(f"No labels found for net '{net_name}'")
return connections return connections
logger.debug(f"Found {len(net_label_positions)} labels for net '{net_name}'") logger.debug(f"Found {len(net_label_positions)} labels for net '{net_name}'")
# 2. Find all wires connected to these label positions # 2. Find all wires connected to these label positions.
if not hasattr(schematic, "wire"): # A missing wire attribute is fine — all_match_points will still
logger.warning("Schematic has no wires") # include label positions, so label-at-pin connections are detected.
return connections connected_wire_points: set[tuple[float, float]] = set()
if not hasattr(schematic, "wire"):
connected_wire_points = set() logger.debug("Schematic has no wires — will match labels to pins directly")
for wire in schematic.wire:
if hasattr(wire, "pts") and hasattr(wire.pts, "xy"): for wire in (schematic.wire if hasattr(schematic, "wire") else []):
# Get all points in this wire (polyline) if hasattr(wire, "pts") and hasattr(wire.pts, "xy"):
wire_points = [] # Get all points in this wire (polyline)
for point in wire.pts.xy: wire_points = []
if hasattr(point, "value"): for point in wire.pts.xy:
wire_points.append([float(point.value[0]), float(point.value[1])]) 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 # Check if any wire point touches a label
for wire_pt in wire_points: wire_connected = False
for label_pt in net_label_positions: for wire_pt in wire_points:
if points_coincide(wire_pt, label_pt): for label_pt in net_label_positions:
wire_connected = True if points_coincide(wire_pt, label_pt):
break wire_connected = True
if wire_connected: break
break if wire_connected:
break
# If this wire is connected to the net, add all its points
if wire_connected: # If this wire is connected to the net, add all its points
for pt in wire_points: if wire_connected:
connected_wire_points.add((pt[0], pt[1])) 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") # Build match points: union of wire endpoints AND label positions.
return connections # This handles the valid KiCad style where a net label is placed
# directly at a pin endpoint with no wire segment in between.
logger.debug( all_match_points = connected_wire_points | {(p[0], p[1]) for p in net_label_positions}
f"Found {len(connected_wire_points)} wire connection points for net '{net_name}'"
) if not all_match_points:
logger.debug(f"No connection points found for net '{net_name}'")
# 3. Find component pins at wire endpoints return connections
if not hasattr(schematic, "symbol"):
logger.warning("Schematic has no symbols") logger.debug(
return connections f"Found {len(connected_wire_points)} wire points, "
f"{len(net_label_positions)} direct label positions, "
# Create pin locator for accurate pin matching (if schematic_path available) f"{len(all_match_points)} total match points for net '{net_name}'"
locator = None )
if schematic_path and WIRE_MANAGER_AVAILABLE:
locator = PinLocator() # 3. Find component pins at wire endpoints
if not hasattr(schematic, "symbol"):
for symbol in schematic.symbol: logger.warning("Schematic has no symbols")
# Skip template symbols return connections
if not hasattr(symbol.property, "Reference"):
continue # Create pin locator for accurate pin matching (if schematic_path available)
locator = None
ref = symbol.property.Reference.value if schematic_path and WIRE_MANAGER_AVAILABLE:
if ref.startswith("_TEMPLATE"): locator = PinLocator()
continue
for symbol in schematic.symbol:
# Get lib_id for pin location lookup # Skip template symbols
lib_id = symbol.lib_id.value if hasattr(symbol, "lib_id") else None if not hasattr(symbol.property, "Reference"):
if not lib_id: continue
continue
ref = symbol.property.Reference.value
# If we have PinLocator and schematic_path, do accurate pin matching if ref.startswith("_TEMPLATE"):
if locator and schematic_path: continue
try:
# Get all pins for this symbol # Get lib_id for pin location lookup
pins = locator.get_symbol_pins(schematic_path, lib_id) lib_id = symbol.lib_id.value if hasattr(symbol, "lib_id") else None
if not pins: if not lib_id:
continue continue
# Check each pin # If we have PinLocator and schematic_path, do accurate pin matching
for pin_num, pin_data in pins.items(): if locator and schematic_path:
# Get pin location try:
pin_loc = locator.get_pin_location(schematic_path, ref, pin_num) # Get all pins for this symbol
if not pin_loc: pins = locator.get_symbol_pins(schematic_path, lib_id)
continue if not pins:
continue
# Check if pin coincides with any wire point
for wire_pt_tup in connected_wire_points: # Check each pin
if points_coincide(pin_loc, list(wire_pt_tup)): for pin_num, pin_data in pins.items():
connections.append({"component": ref, "pin": pin_num}) # Get pin location
break # Pin found, no need to check more wire points pin_loc = locator.get_pin_location(schematic_path, ref, pin_num)
if not pin_loc:
except Exception as e: continue
logger.warning(f"Error matching pins for {ref}: {e}")
# Fall back to proximity matching # Check if pin coincides with any match point
pass for wire_pt_tup in all_match_points:
if points_coincide(pin_loc, list(wire_pt_tup)):
# Fallback: proximity-based matching if no PinLocator connections.append({"component": ref, "pin": pin_num})
if not locator or not schematic_path: break # Pin found, no need to check more wire points
symbol_pos = symbol.at.value if hasattr(symbol, "at") else None
if not symbol_pos: except Exception as e:
continue logger.warning(f"Error matching pins for {ref}: {e}")
# Fall back to proximity matching
symbol_x = float(symbol_pos[0]) pass
symbol_y = float(symbol_pos[1])
# Fallback: proximity-based matching if no PinLocator
# Check if symbol is near any wire point (within 10mm) if not locator or not schematic_path:
for wire_pt_tup in connected_wire_points: symbol_pos = symbol.at.value if hasattr(symbol, "at") else None
dist = ( if not symbol_pos:
(symbol_x - wire_pt_tup[0]) ** 2 + (symbol_y - wire_pt_tup[1]) ** 2 continue
) ** 0.5
if dist < 10.0: # 10mm proximity threshold symbol_x = float(symbol_pos[0])
connections.append({"component": ref, "pin": "unknown"}) symbol_y = float(symbol_pos[1])
break # Only add once per component
# Check if symbol is near any match point (within 10mm)
logger.info(f"Found {len(connections)} connections for net '{net_name}'") for wire_pt_tup in all_match_points:
return connections dist = (
(symbol_x - wire_pt_tup[0]) ** 2 + (symbol_y - wire_pt_tup[1]) ** 2
except Exception as e: ) ** 0.5
logger.error(f"Error getting net connections: {e}") if dist < 10.0: # 10mm proximity threshold
import traceback connections.append({"component": ref, "pin": "unknown"})
break # Only add once per component
logger.error(traceback.format_exc())
return [] logger.info(f"Found {len(connections)} connections for net '{net_name}'")
return connections
@staticmethod
def generate_netlist( except Exception as e:
schematic: Schematic, schematic_path: Optional[Path] = None logger.error(f"Error getting net connections: {e}")
) -> Dict[str, Any]: import traceback
"""
Generate a netlist from the schematic logger.error(traceback.format_exc())
return []
Args:
schematic: Schematic object @staticmethod
schematic_path: Optional path to schematic file (enables accurate pin matching def generate_netlist(
via PinLocator; without it, only one connection per component is found) schematic: Schematic, schematic_path: Optional[Path] = None
) -> Dict[str, Any]:
Returns: """
Dictionary with net information: Generate a netlist from the schematic
{
"nets": [ Args:
{ schematic: Schematic object
"name": "VCC", schematic_path: Optional path to schematic file (enables accurate pin matching
"connections": [ via PinLocator; without it, only one connection per component is found)
{"component": "R1", "pin": "1"},
{"component": "C1", "pin": "1"} Returns:
] Dictionary with net information:
}, {
... "nets": [
], {
"components": [ "name": "VCC",
{"reference": "R1", "value": "10k", "footprint": "..."}, "connections": [
... {"component": "R1", "pin": "1"},
] {"component": "C1", "pin": "1"}
} ]
""" },
try: ...
netlist = {"nets": [], "components": []} ],
"components": [
# Gather all components {"reference": "R1", "value": "10k", "footprint": "..."},
if hasattr(schematic, "symbol"): ...
for symbol in schematic.symbol: ]
component_info = { }
"reference": symbol.property.Reference.value, """
"value": ( try:
symbol.property.Value.value if hasattr(symbol.property, "Value") else "" netlist = {"nets": [], "components": []}
),
"footprint": ( # Gather all components
symbol.property.Footprint.value if hasattr(schematic, "symbol"):
if hasattr(symbol.property, "Footprint") for symbol in schematic.symbol:
else "" component_info = {
), "reference": symbol.property.Reference.value,
} "value": (
netlist["components"].append(component_info) symbol.property.Value.value if hasattr(symbol.property, "Value") else ""
),
# Gather all nets from labels "footprint": (
if hasattr(schematic, "label"): symbol.property.Footprint.value
net_names = set() if hasattr(symbol.property, "Footprint")
for label in schematic.label: else ""
if hasattr(label, "value"): ),
net_names.add(label.value) }
netlist["components"].append(component_info)
# For each net, get connections
for net_name in net_names: # Gather all nets from labels
connections = ConnectionManager.get_net_connections( if hasattr(schematic, "label"):
schematic, net_name, schematic_path net_names = set()
) for label in schematic.label:
if connections: if hasattr(label, "value"):
netlist["nets"].append({"name": net_name, "connections": connections}) net_names.add(label.value)
logger.info( # For each net, get connections
f"Generated netlist with {len(netlist['nets'])} nets and {len(netlist['components'])} components" for net_name in net_names:
) connections = ConnectionManager.get_net_connections(
return netlist schematic, net_name, schematic_path
)
except Exception as e: if connections:
logger.error(f"Error generating netlist: {e}") netlist["nets"].append({"name": net_name, "connections": connections})
return {"nets": [], "components": []}
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": []}

View File

@@ -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