fix: generate_netlist schematic_path, PinLocator cache, server.ts Python detection

- connection_schematic.py: generate_netlist() now accepts schematic_path param,
  threaded through to get_net_connections() so PinLocator is actually invoked
  (previously only 1 connection per component was returned due to fallback break)
- kicad_interface.py: pass schematic_path to generate_netlist()
- pin_locator.py: add _schematic_cache to avoid loading Schematic() once per pin
  (was causing timeout: O(nets x components x pins) Schematic() calls)
- server.ts: remove fragile PYTHONPATH?.includes('KiCad') condition,
  always prefer KiCAD bundled Python on Windows when executable exists
- CHANGELOG.md: document fixes under v2.2.0-alpha
This commit is contained in:
Tom
2026-02-28 01:23:36 +01:00
parent 2945b52eae
commit 76503b144c
5 changed files with 967 additions and 650 deletions

View File

@@ -10,11 +10,13 @@ logger = logging.getLogger(__name__)
try:
from commands.wire_manager import WireManager
from commands.pin_locator import PinLocator
WIRE_MANAGER_AVAILABLE = True
except ImportError:
logger.warning("WireManager/PinLocator not available")
WIRE_MANAGER_AVAILABLE = False
class ConnectionManager:
"""Manage connections between components in schematics"""
@@ -29,7 +31,12 @@ class ConnectionManager:
return cls._pin_locator
@staticmethod
def add_wire(schematic_path: Path, start_point: list, end_point: list, properties: dict = None):
def add_wire(
schematic_path: Path,
start_point: list,
end_point: list,
properties: dict = None,
):
"""
Add a wire between two points using WireManager
@@ -47,11 +54,18 @@ class ConnectionManager:
logger.error("WireManager not available")
return False
stroke_width = properties.get('stroke_width', 0) if properties else 0
stroke_type = properties.get('stroke_type', 'default') if properties else 'default'
stroke_width = properties.get("stroke_width", 0) if properties else 0
stroke_type = (
properties.get("stroke_type", "default") if properties else "default"
)
success = WireManager.add_wire(schematic_path, start_point, end_point,
stroke_width=stroke_width, stroke_type=stroke_type)
success = WireManager.add_wire(
schematic_path,
start_point,
end_point,
stroke_width=stroke_width,
stroke_type=stroke_type,
)
return success
except Exception as e:
logger.error(f"Error adding wire: {e}")
@@ -70,7 +84,7 @@ class ConnectionManager:
[x, y] coordinates or None if pin not found
"""
try:
if not hasattr(symbol, 'pin'):
if not hasattr(symbol, "pin"):
logger.warning(f"Symbol {symbol.property.Reference.value} has no pins")
return None
@@ -82,7 +96,9 @@ class ConnectionManager:
break
if not target_pin:
logger.warning(f"Pin '{pin_name}' not found on {symbol.property.Reference.value}")
logger.warning(
f"Pin '{pin_name}' not found on {symbol.property.Reference.value}"
)
return None
# Get pin location relative to symbol
@@ -101,8 +117,14 @@ class ConnectionManager:
return None
@staticmethod
def add_connection(schematic_path: Path, source_ref: str, source_pin: str,
target_ref: str, target_pin: str, routing: str = 'direct'):
def add_connection(
schematic_path: Path,
source_ref: str,
source_pin: str,
target_ref: str,
target_pin: str,
routing: str = "direct",
):
"""
Add a wire connection between two component pins
@@ -128,31 +150,41 @@ class ConnectionManager:
return False
# Get pin locations
source_loc = locator.get_pin_location(schematic_path, source_ref, source_pin)
target_loc = locator.get_pin_location(schematic_path, target_ref, target_pin)
source_loc = locator.get_pin_location(
schematic_path, source_ref, source_pin
)
target_loc = locator.get_pin_location(
schematic_path, target_ref, target_pin
)
if not source_loc or not target_loc:
logger.error("Could not determine pin locations")
return False
# Create wire based on routing style
if routing == 'direct':
if routing == "direct":
# Simple direct wire
success = WireManager.add_wire(schematic_path, source_loc, target_loc)
elif routing == 'orthogonal_h':
elif routing == "orthogonal_h":
# Orthogonal routing (horizontal first)
path = WireManager.create_orthogonal_path(source_loc, target_loc, prefer_horizontal_first=True)
path = WireManager.create_orthogonal_path(
source_loc, target_loc, prefer_horizontal_first=True
)
success = WireManager.add_polyline_wire(schematic_path, path)
elif routing == 'orthogonal_v':
elif routing == "orthogonal_v":
# Orthogonal routing (vertical first)
path = WireManager.create_orthogonal_path(source_loc, target_loc, prefer_horizontal_first=False)
path = WireManager.create_orthogonal_path(
source_loc, target_loc, prefer_horizontal_first=False
)
success = WireManager.add_polyline_wire(schematic_path, path)
else:
logger.error(f"Unknown routing style: {routing}")
return False
if success:
logger.info(f"Connected {source_ref}/{source_pin} to {target_ref}/{target_pin} (routing: {routing})")
logger.info(
f"Connected {source_ref}/{source_pin} to {target_ref}/{target_pin} (routing: {routing})"
)
return True
else:
return False
@@ -160,6 +192,7 @@ class ConnectionManager:
except Exception as e:
logger.error(f"Error adding connection: {e}")
import traceback
logger.error(traceback.format_exc())
return False
@@ -177,13 +210,12 @@ class ConnectionManager:
Label object or None on error
"""
try:
if not hasattr(schematic, 'label'):
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]}
text=net_name, at={"x": position[0], "y": position[1]}
)
logger.info(f"Added net label '{net_name}' at {position}")
return label
@@ -192,7 +224,9 @@ class ConnectionManager:
return None
@staticmethod
def connect_to_net(schematic_path: Path, component_ref: str, pin_name: str, net_name: str):
def connect_to_net(
schematic_path: Path, component_ref: str, pin_name: str, net_name: str
):
"""
Connect a component pin to a named net using a wire stub and label
@@ -231,7 +265,9 @@ class ConnectionManager:
return False
# Add label at the end of the stub using WireManager
label_success = WireManager.add_label(schematic_path, net_name, stub_end, label_type='label')
label_success = WireManager.add_label(
schematic_path, net_name, stub_end, label_type="label"
)
if not label_success:
logger.error(f"Failed to add net label '{net_name}'")
return False
@@ -242,11 +278,14 @@ class ConnectionManager:
except Exception as e:
logger.error(f"Error connecting to net: {e}")
import traceback
logger.error(traceback.format_exc())
return False
@staticmethod
def get_net_connections(schematic: Schematic, net_name: str, schematic_path: Optional[Path] = None):
def get_net_connections(
schematic: Schematic, net_name: str, schematic_path: Optional[Path] = None
):
"""
Get all connections for a named net using wire graph analysis
@@ -273,14 +312,14 @@ class ConnectionManager:
return dx < tolerance and dy < tolerance
# 1. Find all labels with this net name
if not hasattr(schematic, 'label'):
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'):
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])])
@@ -288,21 +327,25 @@ class ConnectionManager:
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}'")
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'):
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'):
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])])
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
@@ -323,10 +366,12 @@ class ConnectionManager:
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}'")
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'):
if not hasattr(schematic, "symbol"):
logger.warning("Schematic has no symbols")
return connections
@@ -337,15 +382,15 @@ class ConnectionManager:
for symbol in schematic.symbol:
# Skip template symbols
if not hasattr(symbol.property, 'Reference'):
if not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if ref.startswith('_TEMPLATE'):
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
lib_id = symbol.lib_id.value if hasattr(symbol, "lib_id") else None
if not lib_id:
continue
@@ -360,17 +405,18 @@ class ConnectionManager:
# 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)
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 in connected_wire_points:
if points_coincide(pin_loc, list(wire_pt)):
connections.append({
"component": ref,
"pin": pin_num
})
connections.append(
{"component": ref, "pin": pin_num}
)
break # Pin found, no need to check more wire points
except Exception as e:
@@ -380,7 +426,7 @@ class ConnectionManager:
# 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
symbol_pos = symbol.at.value if hasattr(symbol, "at") else None
if not symbol_pos:
continue
@@ -389,12 +435,11 @@ class ConnectionManager:
# Check if symbol is near any wire point (within 10mm)
for wire_pt in connected_wire_points:
dist = ((symbol_x - wire_pt[0])**2 + (symbol_y - wire_pt[1])**2)**0.5
dist = (
(symbol_x - wire_pt[0]) ** 2 + (symbol_y - wire_pt[1]) ** 2
) ** 0.5
if dist < 10.0: # 10mm proximity threshold
connections.append({
"component": ref,
"pin": "unknown"
})
connections.append({"component": ref, "pin": "unknown"})
break # Only add once per component
logger.info(f"Found {len(connections)} connections for net '{net_name}'")
@@ -403,14 +448,20 @@ class ConnectionManager:
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):
def generate_netlist(schematic: Schematic, schematic_path: Optional[Path] = None):
"""
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:
{
@@ -431,47 +482,58 @@ class ConnectionManager:
}
"""
try:
netlist = {
"nets": [],
"components": []
}
netlist = {"nets": [], "components": []}
# Gather all components
if hasattr(schematic, 'symbol'):
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 ""
"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'):
if hasattr(schematic, "label"):
net_names = set()
for label in schematic.label:
if hasattr(label, 'value'):
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)
connections = ConnectionManager.get_net_connections(
schematic, net_name, schematic_path
)
if connections:
netlist["nets"].append({
"name": net_name,
"connections": connections
})
netlist["nets"].append(
{"name": net_name, "connections": connections}
)
logger.info(f"Generated netlist with {len(netlist['nets'])} nets and {len(netlist['components'])} 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": []}
if __name__ == '__main__':
if __name__ == "__main__":
# Example Usage (for testing)
from schematic import SchematicManager # Assuming schematic.py is in the same directory
from schematic import (
SchematicManager,
) # Assuming schematic.py is in the same directory
# Create a new schematic
test_sch = SchematicManager.create_schematic("ConnectionTestSchematic")

View File

@@ -14,7 +14,7 @@ import sexpdata
from sexpdata import Symbol
from skip import Schematic
logger = logging.getLogger('kicad_interface')
logger = logging.getLogger("kicad_interface")
class PinLocator:
@@ -23,6 +23,7 @@ class PinLocator:
def __init__(self):
"""Initialize pin locator with empty cache"""
self.pin_definition_cache = {} # Cache: "lib_id:symbol_name" -> pin_data
self._schematic_cache: Dict[str, object] = {} # Cache: path -> loaded Schematic
@staticmethod
def parse_symbol_definition(symbol_def: list) -> Dict[str, Dict]:
@@ -47,39 +48,39 @@ class PinLocator:
return
# Check if this is a pin definition
if len(sexp) > 0 and sexp[0] == Symbol('pin'):
if len(sexp) > 0 and sexp[0] == Symbol("pin"):
# Pin format: (pin type shape (at x y angle) (length len) (name "name") (number "num"))
pin_data = {
'x': 0,
'y': 0,
'angle': 0,
'length': 0,
'name': '',
'number': '',
'type': str(sexp[1]) if len(sexp) > 1 else 'passive'
"x": 0,
"y": 0,
"angle": 0,
"length": 0,
"name": "",
"number": "",
"type": str(sexp[1]) if len(sexp) > 1 else "passive",
}
# Extract pin attributes
for item in sexp:
if isinstance(item, list) and len(item) > 0:
if item[0] == Symbol('at') and len(item) >= 3:
pin_data['x'] = float(item[1])
pin_data['y'] = float(item[2])
if item[0] == Symbol("at") and len(item) >= 3:
pin_data["x"] = float(item[1])
pin_data["y"] = float(item[2])
if len(item) >= 4:
pin_data['angle'] = float(item[3])
pin_data["angle"] = float(item[3])
elif item[0] == Symbol('length') and len(item) >= 2:
pin_data['length'] = float(item[1])
elif item[0] == Symbol("length") and len(item) >= 2:
pin_data["length"] = float(item[1])
elif item[0] == Symbol('name') and len(item) >= 2:
pin_data['name'] = str(item[1]).strip('"')
elif item[0] == Symbol("name") and len(item) >= 2:
pin_data["name"] = str(item[1]).strip('"')
elif item[0] == Symbol('number') and len(item) >= 2:
pin_data['number'] = str(item[1]).strip('"')
elif item[0] == Symbol("number") and len(item) >= 2:
pin_data["number"] = str(item[1]).strip('"')
# Store by pin number
if pin_data['number']:
pins[pin_data['number']] = pin_data
if pin_data["number"]:
pins[pin_data["number"]] = pin_data
# Recurse into sublists
for item in sexp:
@@ -108,7 +109,7 @@ class PinLocator:
try:
# Read schematic
with open(schematic_path, 'r', encoding='utf-8') as f:
with open(schematic_path, "r", encoding="utf-8") as f:
sch_content = f.read()
sch_data = sexpdata.loads(sch_content)
@@ -116,7 +117,11 @@ class PinLocator:
# Find lib_symbols section
lib_symbols = None
for item in sch_data:
if isinstance(item, list) and len(item) > 0 and item[0] == Symbol('lib_symbols'):
if (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("lib_symbols")
):
lib_symbols = item
break
@@ -126,7 +131,11 @@ class PinLocator:
# Find the specific symbol definition
for item in lib_symbols[1:]: # Skip 'lib_symbols' itself
if isinstance(item, list) and len(item) > 1 and item[0] == Symbol('symbol'):
if (
isinstance(item, list)
and len(item) > 1
and item[0] == Symbol("symbol")
):
symbol_name = str(item[1]).strip('"')
if symbol_name == lib_id:
# Found the symbol, parse pins
@@ -141,6 +150,7 @@ class PinLocator:
except Exception as e:
logger.error(f"Error getting symbol pins: {e}")
import traceback
logger.error(traceback.format_exc())
return {}
@@ -169,8 +179,9 @@ class PinLocator:
return (rotated_x, rotated_y)
def get_pin_location(self, schematic_path: Path, symbol_reference: str,
pin_number: str) -> Optional[List[float]]:
def get_pin_location(
self, schematic_path: Path, symbol_reference: str, pin_number: str
) -> Optional[List[float]]:
"""
Get the absolute location of a pin on a symbol instance
@@ -184,7 +195,11 @@ class PinLocator:
"""
try:
# Load schematic with kicad-skip to get symbol instance
sch = Schematic(str(schematic_path))
# Use cache to avoid reloading the file for every pin lookup
sch_key = str(schematic_path)
if sch_key not in self._schematic_cache:
self._schematic_cache[sch_key] = Schematic(sch_key)
sch = self._schematic_cache[sch_key]
# Find the symbol instance
target_symbol = None
@@ -205,12 +220,16 @@ class PinLocator:
symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0
# Get symbol lib_id
lib_id = target_symbol.lib_id.value if hasattr(target_symbol, 'lib_id') else None
lib_id = (
target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
)
if not lib_id:
logger.error(f"Symbol {symbol_reference} has no lib_id")
return None
logger.debug(f"Symbol {symbol_reference}: pos=({symbol_x}, {symbol_y}), rot={symbol_rotation}, lib_id={lib_id}")
logger.debug(
f"Symbol {symbol_reference}: pos=({symbol_x}, {symbol_y}), rot={symbol_rotation}, lib_id={lib_id}"
)
# Get pin definitions for this symbol
pins = self.get_symbol_pins(schematic_path, lib_id)
@@ -220,36 +239,49 @@ class PinLocator:
# Find the requested pin
if pin_number not in pins:
logger.error(f"Pin {pin_number} not found on {symbol_reference}. Available pins: {list(pins.keys())}")
logger.error(
f"Pin {pin_number} not found on {symbol_reference}. Available pins: {list(pins.keys())}"
)
return None
pin_data = pins[pin_number]
# Get pin position relative to symbol origin
pin_rel_x = pin_data['x']
pin_rel_y = pin_data['y']
pin_rel_x = pin_data["x"]
pin_rel_y = pin_data["y"]
logger.debug(f"Pin {pin_number} relative position: ({pin_rel_x}, {pin_rel_y})")
logger.debug(
f"Pin {pin_number} relative position: ({pin_rel_x}, {pin_rel_y})"
)
# Apply symbol rotation to pin position
if symbol_rotation != 0:
pin_rel_x, pin_rel_y = self.rotate_point(pin_rel_x, pin_rel_y, symbol_rotation)
logger.debug(f"After rotation {symbol_rotation}°: ({pin_rel_x}, {pin_rel_y})")
pin_rel_x, pin_rel_y = self.rotate_point(
pin_rel_x, pin_rel_y, symbol_rotation
)
logger.debug(
f"After rotation {symbol_rotation}°: ({pin_rel_x}, {pin_rel_y})"
)
# Calculate absolute position
abs_x = symbol_x + pin_rel_x
abs_y = symbol_y + pin_rel_y
logger.info(f"Pin {symbol_reference}/{pin_number} located at ({abs_x}, {abs_y})")
logger.info(
f"Pin {symbol_reference}/{pin_number} located at ({abs_x}, {abs_y})"
)
return [abs_x, abs_y]
except Exception as e:
logger.error(f"Error getting pin location: {e}")
import traceback
logger.error(traceback.format_exc())
return None
def get_all_symbol_pins(self, schematic_path: Path, symbol_reference: str) -> Dict[str, List[float]]:
def get_all_symbol_pins(
self, schematic_path: Path, symbol_reference: str
) -> Dict[str, List[float]]:
"""
Get locations of all pins on a symbol instance
@@ -261,8 +293,11 @@ class PinLocator:
Dictionary mapping pin number -> [x, y] coordinates
"""
try:
# Load schematic
sch = Schematic(str(schematic_path))
# Load schematic (use cache)
sch_key = str(schematic_path)
if sch_key not in self._schematic_cache:
self._schematic_cache[sch_key] = Schematic(sch_key)
sch = self._schematic_cache[sch_key]
# Find symbol
target_symbol = None
@@ -276,7 +311,9 @@ class PinLocator:
return {}
# Get lib_id
lib_id = target_symbol.lib_id.value if hasattr(target_symbol, 'lib_id') else None
lib_id = (
target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
)
if not lib_id:
logger.error(f"Symbol {symbol_reference} has no lib_id")
return {}
@@ -289,7 +326,9 @@ class PinLocator:
# Calculate location for each pin
result = {}
for pin_num in pins.keys():
location = self.get_pin_location(schematic_path, symbol_reference, pin_num)
location = self.get_pin_location(
schematic_path, symbol_reference, pin_num
)
if location:
result[pin_num] = location
@@ -301,10 +340,11 @@ class PinLocator:
return {}
if __name__ == '__main__':
if __name__ == "__main__":
# Test pin location discovery
import sys
sys.path.insert(0, '/home/chris/MCP/KiCAD-MCP-Server/python')
sys.path.insert(0, "/home/chris/MCP/KiCAD-MCP-Server/python")
from pathlib import Path
from commands.component_schematic import ComponentManager
@@ -316,8 +356,10 @@ if __name__ == '__main__':
print("=" * 80)
# Create test schematic with components (cross-platform temp directory)
test_path = Path(tempfile.gettempdir()) / 'test_pin_locator.kicad_sch'
template_path = Path('/home/chris/MCP/KiCAD-MCP-Server/python/templates/template_with_symbols_expanded.kicad_sch')
test_path = Path(tempfile.gettempdir()) / "test_pin_locator.kicad_sch"
template_path = Path(
"/home/chris/MCP/KiCAD-MCP-Server/python/templates/template_with_symbols_expanded.kicad_sch"
)
shutil.copy(template_path, test_path)
print(f"\n✓ Created test schematic: {test_path}")
@@ -327,11 +369,25 @@ if __name__ == '__main__':
sch = SchematicManager.load_schematic(str(test_path))
# Add resistor at (100, 100), rotation 0
r1_def = {'type': 'R', 'reference': 'R1', 'value': '10k', 'x': 100, 'y': 100, 'rotation': 0}
r1_def = {
"type": "R",
"reference": "R1",
"value": "10k",
"x": 100,
"y": 100,
"rotation": 0,
}
ComponentManager.add_component(sch, r1_def, test_path)
# Add capacitor at (150, 100), rotation 90
c1_def = {'type': 'C', 'reference': 'C1', 'value': '100nF', 'x': 150, 'y': 100, 'rotation': 90}
c1_def = {
"type": "C",
"reference": "C1",
"value": "100nF",
"x": 150,
"y": 100,
"rotation": 90,
}
ComponentManager.add_component(sch, c1_def, test_path)
SchematicManager.save_schematic(sch, str(test_path))