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:
@@ -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": []}
|
||||||
|
|||||||
427
tests/test_label_at_pin_net_connections.py
Normal file
427
tests/test_label_at_pin_net_connections.py
Normal 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
|
||||||
Reference in New Issue
Block a user