fix: project-local library resolution + copy_routing_pattern geometric fallback + TypeScript tool
- fix: DynamicSymbolLoader reads project sym-lib-table before global dirs add_schematic_component now finds symbols from project-local .kicad_sym files project_path derived automatically from schematic file path - fix: place_component reloads FootprintLibraryManager with project_path new boardPath parameter passed to place_component tool (TypeScript + Python) _handle_place_component wrapper recreates LibraryManager per project - fix: copy_routing_pattern geometric fallback when pads have no nets primary filter: net-based (when pads are assigned to nets) fallback: bounding box of source footprint pads +5mm tolerance filterMethod field in response indicates which mode was used - feat: register copy_routing_pattern as MCP tool in routing.ts sourceRefs, targetRefs, includeVias, traceWidth parameters Live tested: ESP32 + 2x TMC2209 in Test3 project 13 traces U2 routed, copy_routing_pattern copied all 13 to U3 offset Y+30mm correct, 26 total traces verified
This commit is contained in:
@@ -29,8 +29,9 @@ class DynamicSymbolLoader:
|
||||
- Parent symbols must appear BEFORE child symbols that use (extends ...)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, project_path: Optional[Path] = None):
|
||||
self.symbol_cache = {} # Cache: "lib:symbol" -> raw text block
|
||||
self.project_path = project_path # Project directory for project-specific libraries
|
||||
|
||||
def find_kicad_symbol_libraries(self) -> List[Path]:
|
||||
"""Find all KiCad symbol library directories"""
|
||||
@@ -50,14 +51,74 @@ class DynamicSymbolLoader:
|
||||
return [p for p in possible_paths if p.exists() and p.is_dir()]
|
||||
|
||||
def find_library_file(self, library_name: str) -> Optional[Path]:
|
||||
"""Find the .kicad_sym file for a given library name"""
|
||||
"""Find the .kicad_sym file for a given library name.
|
||||
|
||||
Search order:
|
||||
1. Project-specific sym-lib-table (if project_path is set)
|
||||
2. Global KiCad symbol library directories
|
||||
"""
|
||||
# 1. Check project-specific sym-lib-table
|
||||
if self.project_path:
|
||||
project_table = Path(self.project_path) / "sym-lib-table"
|
||||
if project_table.exists():
|
||||
resolved = self._resolve_library_from_table(project_table, library_name)
|
||||
if resolved:
|
||||
logger.info(f"Found '{library_name}' in project sym-lib-table: {resolved}")
|
||||
return resolved
|
||||
|
||||
# 2. Fall back to global KiCad symbol directories
|
||||
for lib_dir in self.find_kicad_symbol_libraries():
|
||||
lib_file = lib_dir / f"{library_name}.kicad_sym"
|
||||
if lib_file.exists():
|
||||
return lib_file
|
||||
|
||||
logger.warning(f"Library file not found: {library_name}.kicad_sym")
|
||||
return None
|
||||
|
||||
def _resolve_library_from_table(self, table_path: Path, library_name: str) -> Optional[Path]:
|
||||
"""Parse a sym-lib-table file and return the resolved path for the given library nickname."""
|
||||
try:
|
||||
with open(table_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
lib_pattern = r'\(lib\s+\(name\s+"?([^"\)\s]+)"?\)\s*\(type\s+[^)]+\)\s*\(uri\s+"?([^"\)\s]+)"?'
|
||||
for match in re.finditer(lib_pattern, content, re.IGNORECASE):
|
||||
nickname = match.group(1)
|
||||
if nickname != library_name:
|
||||
continue
|
||||
uri = match.group(2)
|
||||
resolved = self._resolve_sym_uri(uri)
|
||||
if resolved and Path(resolved).exists():
|
||||
return Path(resolved)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not parse sym-lib-table {table_path}: {e}")
|
||||
return None
|
||||
|
||||
def _resolve_sym_uri(self, uri: str) -> Optional[str]:
|
||||
"""Resolve environment variables in a sym-lib-table URI."""
|
||||
env_map = {
|
||||
"KICAD9_SYMBOL_DIR": [
|
||||
"C:/Program Files/KiCad/9.0/share/kicad/symbols",
|
||||
"/usr/share/kicad/symbols",
|
||||
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols",
|
||||
],
|
||||
"KICAD8_SYMBOL_DIR": [
|
||||
"C:/Program Files/KiCad/8.0/share/kicad/symbols",
|
||||
],
|
||||
"KIPRJMOD": [str(self.project_path)] if self.project_path else [],
|
||||
}
|
||||
result = uri
|
||||
for var, candidates in env_map.items():
|
||||
if f"${{{var}}}" in result:
|
||||
for candidate in candidates:
|
||||
candidate_path = result.replace(f"${{{var}}}", candidate)
|
||||
if Path(candidate_path).exists():
|
||||
return candidate_path
|
||||
# Fallback: try OS env
|
||||
if var in os.environ:
|
||||
return result.replace(f"${{{var}}}", os.environ[var])
|
||||
return result
|
||||
|
||||
def _extract_symbol_block(self, text: str, symbol_name: str) -> Optional[str]:
|
||||
"""
|
||||
Extract a complete symbol block from a library or schematic file by matching
|
||||
@@ -345,11 +406,18 @@ class DynamicSymbolLoader:
|
||||
footprint: str = "",
|
||||
x: float = 0,
|
||||
y: float = 0,
|
||||
project_path: Optional[Path] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
High-level: ensure symbol definition exists in schematic, then add an instance.
|
||||
This is the main entry point for adding components.
|
||||
|
||||
Args:
|
||||
project_path: Optional project directory. When set, project-specific
|
||||
sym-lib-table is also searched for the library file.
|
||||
"""
|
||||
if project_path:
|
||||
self.project_path = project_path
|
||||
# Ensure symbol definition is in lib_symbols
|
||||
self.inject_symbol_into_schematic(schematic_path, library_name, symbol_name)
|
||||
|
||||
|
||||
@@ -729,29 +729,72 @@ class RoutingCommands:
|
||||
|
||||
# Collect all nets connected to source components
|
||||
source_nets = set()
|
||||
source_pad_positions = [] # (x, y) in nm for geometric fallback
|
||||
for ref in source_refs:
|
||||
fp = footprints[ref]
|
||||
for pad in fp.Pads():
|
||||
net_name = pad.GetNetname()
|
||||
if net_name and net_name != "":
|
||||
source_nets.add(net_name)
|
||||
pos = pad.GetPosition()
|
||||
source_pad_positions.append((pos.x, pos.y))
|
||||
|
||||
# Collect traces and vias connected to source nets
|
||||
# Build bounding box around source pads (with 5mm tolerance in nm)
|
||||
TOLERANCE_NM = int(5 * scale)
|
||||
if source_pad_positions:
|
||||
xs = [p[0] for p in source_pad_positions]
|
||||
ys = [p[1] for p in source_pad_positions]
|
||||
bbox_x1 = min(xs) - TOLERANCE_NM
|
||||
bbox_x2 = max(xs) + TOLERANCE_NM
|
||||
bbox_y1 = min(ys) - TOLERANCE_NM
|
||||
bbox_y2 = max(ys) + TOLERANCE_NM
|
||||
else:
|
||||
# Fall back to component position ± 25mm
|
||||
sp = source_fp.GetPosition()
|
||||
bbox_x1 = sp.x - int(25 * scale)
|
||||
bbox_x2 = sp.x + int(25 * scale)
|
||||
bbox_y1 = sp.y - int(25 * scale)
|
||||
bbox_y2 = sp.y + int(25 * scale)
|
||||
|
||||
def point_in_bbox(px: int, py: int) -> bool:
|
||||
return bbox_x1 <= px <= bbox_x2 and bbox_y1 <= py <= bbox_y2
|
||||
|
||||
# Collect traces: by net name (if available) OR by geometric proximity
|
||||
use_net_filter = len(source_nets) > 0
|
||||
traces_to_copy = []
|
||||
vias_to_copy = []
|
||||
|
||||
for track in list(self.board.Tracks()):
|
||||
if track.GetNetname() not in source_nets:
|
||||
continue
|
||||
|
||||
is_via = track.Type() == pcbnew.PCB_VIA_T
|
||||
|
||||
if use_net_filter:
|
||||
# Primary: net-based filter
|
||||
if track.GetNetname() not in source_nets:
|
||||
continue
|
||||
else:
|
||||
# Fallback: geometric filter – trace start OR end inside source bbox
|
||||
if is_via:
|
||||
pos = track.GetPosition()
|
||||
if not point_in_bbox(pos.x, pos.y):
|
||||
continue
|
||||
else:
|
||||
s = track.GetStart()
|
||||
e = track.GetEnd()
|
||||
if not (point_in_bbox(s.x, s.y) or point_in_bbox(e.x, e.y)):
|
||||
continue
|
||||
|
||||
if is_via:
|
||||
if include_vias:
|
||||
vias_to_copy.append(track)
|
||||
else:
|
||||
traces_to_copy.append(track)
|
||||
|
||||
filter_method = "net-based" if use_net_filter else "geometric (pads have no nets)"
|
||||
logger.info(
|
||||
f"copy_routing_pattern: {len(traces_to_copy)} traces, "
|
||||
f"{len(vias_to_copy)} vias selected via {filter_method}"
|
||||
)
|
||||
|
||||
# Create new traces with offset
|
||||
created_traces = 0
|
||||
created_vias = 0
|
||||
@@ -796,6 +839,7 @@ class RoutingCommands:
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"Copied routing pattern: {created_traces} traces, {created_vias} vias",
|
||||
"filterMethod": filter_method,
|
||||
"offset": {"x": offset_x / scale, "y": offset_y / scale, "unit": "mm"},
|
||||
"createdTraces": created_traces,
|
||||
"createdVias": created_vias,
|
||||
|
||||
@@ -273,6 +273,7 @@ class KiCADInterface:
|
||||
self.design_rule_commands = DesignRuleCommands(self.board)
|
||||
self.export_commands = ExportCommands(self.board)
|
||||
self.library_commands = LibraryCommands(self.footprint_library)
|
||||
self._current_project_path: Optional[Path] = None # set when boardPath is known
|
||||
|
||||
# Initialize symbol library manager (for searching local KiCad symbol libraries)
|
||||
self.symbol_library_commands = SymbolLibraryCommands()
|
||||
@@ -307,7 +308,7 @@ class KiCADInterface:
|
||||
"add_text": self.board_commands.add_text,
|
||||
"add_board_text": self.board_commands.add_text, # Alias for TypeScript tool
|
||||
# Component commands
|
||||
"place_component": self.component_commands.place_component,
|
||||
"place_component": self._handle_place_component,
|
||||
"move_component": self.component_commands.move_component,
|
||||
"rotate_component": self.component_commands.rotate_component,
|
||||
"delete_component": self.component_commands.delete_component,
|
||||
@@ -579,6 +580,21 @@ class KiCADInterface:
|
||||
logger.error(f"Error loading schematic: {str(e)}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_place_component(self, params):
|
||||
"""Place a component on the PCB, with project-local fp-lib-table support."""
|
||||
from pathlib import Path
|
||||
|
||||
board_path = params.get("boardPath")
|
||||
if board_path:
|
||||
project_path = Path(board_path).parent
|
||||
if project_path != getattr(self, "_current_project_path", None):
|
||||
self._current_project_path = project_path
|
||||
local_lib = FootprintLibraryManager(project_path=project_path)
|
||||
self.component_commands = ComponentCommands(self.board, local_lib)
|
||||
logger.info(f"Reloaded FootprintLibraryManager with project_path={project_path}")
|
||||
|
||||
return self.component_commands.place_component(params)
|
||||
|
||||
def _handle_add_schematic_component(self, params):
|
||||
"""Add a component to a schematic using text-based injection (no sexpdata)"""
|
||||
logger.info("Adding component to schematic")
|
||||
@@ -602,9 +618,13 @@ class KiCADInterface:
|
||||
x = component.get("x", 0)
|
||||
y = component.get("y", 0)
|
||||
|
||||
loader = DynamicSymbolLoader()
|
||||
# Derive project path from schematic path for project-local library resolution
|
||||
schematic_file = Path(schematic_path)
|
||||
derived_project_path = schematic_file.parent
|
||||
|
||||
loader = DynamicSymbolLoader(project_path=derived_project_path)
|
||||
loader.add_component(
|
||||
Path(schematic_path),
|
||||
schematic_file,
|
||||
library,
|
||||
comp_type,
|
||||
reference=reference,
|
||||
@@ -612,6 +632,7 @@ class KiCADInterface:
|
||||
footprint=footprint,
|
||||
x=x,
|
||||
y=y,
|
||||
project_path=derived_project_path,
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user