diff --git a/.gitignore b/.gitignore index af64958..0cff050 100644 --- a/.gitignore +++ b/.gitignore @@ -62,8 +62,11 @@ logs/ # KiCAD *.kicad_pcb-bak +*.kicad_pcb.bak *.kicad_sch-bak +*.kicad_sch.bak *.kicad_pro-bak +*.kicad_pro.bak *.kicad_prl *-backups/ fp-info-cache @@ -82,3 +85,6 @@ data/ # OS Thumbs.db Desktop.ini + +# Personal notes / local contributions (not for upstream) +myContribution/ diff --git a/python/commands/dynamic_symbol_loader.py b/python/commands/dynamic_symbol_loader.py index dcf4373..ce27d6f 100644 --- a/python/commands/dynamic_symbol_loader.py +++ b/python/commands/dynamic_symbol_loader.py @@ -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) diff --git a/python/commands/routing.py b/python/commands/routing.py index 7ac6ffc..d64d19c 100644 --- a/python/commands/routing.py +++ b/python/commands/routing.py @@ -71,6 +71,103 @@ class RoutingCommands: "errorDetails": str(e), } + def route_pad_to_pad(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Route a trace directly from one component pad to another. + + Looks up pad positions automatically, then creates a trace. + Convenience wrapper around route_trace that eliminates the need + for separate get_pad_position calls. + """ + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + from_ref = params.get("fromRef") + from_pad = str(params.get("fromPad", "")) + to_ref = params.get("toRef") + to_pad = str(params.get("toPad", "")) + layer = params.get("layer", "F.Cu") + width = params.get("width") + net = params.get("net") # optional override + + if not from_ref or not from_pad or not to_ref or not to_pad: + return { + "success": False, + "message": "Missing parameters", + "errorDetails": "fromRef, fromPad, toRef, toPad are all required", + } + + scale = 1000000 # nm to mm + + # Find pads + footprints = {fp.GetReference(): fp for fp in self.board.GetFootprints()} + + for ref in [from_ref, to_ref]: + if ref not in footprints: + return { + "success": False, + "message": f"Component not found: {ref}", + "errorDetails": f"'{ref}' does not exist on the board", + } + + def find_pad(ref: str, pad_num: str): + fp = footprints[ref] + for pad in fp.Pads(): + if pad.GetNumber() == pad_num: + return pad + return None + + start_pad = find_pad(from_ref, from_pad) + end_pad = find_pad(to_ref, to_pad) + + if not start_pad: + return { + "success": False, + "message": f"Pad not found: {from_ref} pad {from_pad}", + "errorDetails": f"Check pad number for {from_ref}", + } + if not end_pad: + return { + "success": False, + "message": f"Pad not found: {to_ref} pad {to_pad}", + "errorDetails": f"Check pad number for {to_ref}", + } + + start_pos = start_pad.GetPosition() + end_pos = end_pad.GetPosition() + + # Use net from start pad if not overridden + if not net: + net = start_pad.GetNetname() or end_pad.GetNetname() or "" + + # Delegate to route_trace + result = self.route_trace({ + "start": {"x": start_pos.x / scale, "y": start_pos.y / scale, "unit": "mm"}, + "end": {"x": end_pos.x / scale, "y": end_pos.y / scale, "unit": "mm"}, + "layer": layer, + "width": width, + "net": net, + }) + + if result.get("success"): + result["message"] = f"Routed {from_ref}.{from_pad} → {to_ref}.{to_pad} (net: {net or 'none'})" + result["fromPad"] = {"ref": from_ref, "pad": from_pad, "x": start_pos.x / scale, "y": start_pos.y / scale} + result["toPad"] = {"ref": to_ref, "pad": to_pad, "x": end_pos.x / scale, "y": end_pos.y / scale} + + return result + + except Exception as e: + logger.error(f"Error in route_pad_to_pad: {str(e)}") + return { + "success": False, + "message": "Failed to route pad to pad", + "errorDetails": str(e), + } + def route_trace(self, params: Dict[str, Any]) -> Dict[str, Any]: """Route a trace between two points or pads""" try: @@ -729,29 +826,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 +936,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, diff --git a/python/kicad_interface.py b/python/kicad_interface.py index dbd7675..2fde2a7 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -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,8 @@ 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, + "route_pad_to_pad": self.routing_commands.route_pad_to_pad, + "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 +581,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 +619,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 +633,7 @@ class KiCADInterface: footprint=footprint, x=x, y=y, + project_path=derived_project_path, ) return { diff --git a/python/templates/template_with_symbols.kicad_sch b/python/templates/template_with_symbols.kicad_sch index 3a3f583..f6d31c8 100644 --- a/python/templates/template_with_symbols.kicad_sch +++ b/python/templates/template_with_symbols.kicad_sch @@ -1,4 +1,4 @@ -(kicad_sch (version 20240101) (generator "KiCAD-MCP-Server") +(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server") (uuid a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d) diff --git a/python/templates/template_with_symbols_expanded.kicad_sch b/python/templates/template_with_symbols_expanded.kicad_sch index cffde67..09beebb 100644 --- a/python/templates/template_with_symbols_expanded.kicad_sch +++ b/python/templates/template_with_symbols_expanded.kicad_sch @@ -1,4 +1,4 @@ -(kicad_sch (version 20240101) (generator "KiCAD-MCP-Server") +(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server") (uuid c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f) diff --git a/src/tools/component.ts b/src/tools/component.ts index 24d5367..8c9d773 100644 --- a/src/tools/component.ts +++ b/src/tools/component.ts @@ -57,6 +57,10 @@ export function registerComponentTools( .string() .optional() .describe("Optional layer (e.g., 'F.Cu', 'B.SilkS')"), + boardPath: z + .string() + .optional() + .describe("Path to the .kicad_pcb file – required when using project-local footprint libraries"), }, async ({ componentId, @@ -66,6 +70,7 @@ export function registerComponentTools( footprint, rotation, layer, + boardPath, }) => { logger.debug( `Placing component: ${componentId} at ${position.x},${position.y} ${position.unit}`, @@ -78,6 +83,7 @@ export function registerComponentTools( footprint, rotation, layer, + boardPath, }); return { diff --git a/src/tools/routing.ts b/src/tools/routing.ts index 8f880bb..691a903 100644 --- a/src/tools/routing.ts +++ b/src/tools/routing.ts @@ -321,4 +321,60 @@ export function registerRoutingTools( }; }, ); + + // Route pad to pad tool + server.tool( + "route_pad_to_pad", + "Route a trace directly from one component pad to another without needing separate get_pad_position calls. Automatically looks up pad coordinates and uses the pad's net. Saves token usage compared to the 3-step get_pad_position + get_pad_position + route_trace sequence.", + { + fromRef: z.string().describe("Reference of the source component (e.g. 'U2')"), + fromPad: z.union([z.string(), z.number()]).describe("Pad number on the source component (e.g. '6' or 6)"), + toRef: z.string().describe("Reference of the target component (e.g. 'U1')"), + toPad: z.union([z.string(), z.number()]).describe("Pad number on the target component (e.g. '15' or 15)"), + layer: z.string().optional().describe("PCB layer (default: F.Cu)"), + width: z.number().optional().describe("Trace width in mm (default: board default)"), + net: z.string().optional().describe("Net name override (default: auto-detected from pad)"), + }, + async (args: any) => { + const result = await callKicadScript("route_pad_to_pad", args); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + }, + ); + + // Copy routing pattern tool + server.tool( + "copy_routing_pattern", + "Copy routing pattern (traces and vias) from a group of source components to a matching group of target components. The offset is calculated automatically from the position difference between the first source and first target component. Useful for replicating routing between identical circuit blocks.", + { + sourceRefs: z + .array(z.string()) + .describe("References of the source components (e.g. ['U1', 'R1', 'C1'])"), + targetRefs: z + .array(z.string()) + .describe( + "References of the target components in same order as sourceRefs (e.g. ['U2', 'R2', 'C2'])", + ), + includeVias: z + .boolean() + .optional() + .describe("Also copy vias (default: true)"), + traceWidth: z + .number() + .optional() + .describe("Override trace width in mm (default: keep original width)"), + }, + async (args: any) => { + const result = await callKicadScript("copy_routing_pattern", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, + ); }