diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c6cfe5..5b64ab6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,47 @@ All notable changes to the KiCAD MCP Server project are documented here. +## [2.2.1-alpha] - 2026-02-28 + +### New MCP Tools + +- `edit_schematic_component` – Update properties of a placed symbol in-place (footprint, + value, reference rename). More efficient than delete + re-add: preserves position and UUID. + +### Bug Fixes + +- `add_schematic_component`: `footprint` parameter was accepted but silently ignored – the + value was never passed through to `DynamicSymbolLoader.add_component()` / + `create_component_instance()`. All newly placed symbols always had an empty Footprint + field. Fix: added `footprint: str = ""` to both functions and threaded it through every + call site including the TypeScript tool schema. + +- `delete_schematic_component`: only deleted the first matching instance when duplicate + references existed (e.g. after an aborted add attempt). Root cause: loop used `break` + after the first match. Fix: collect all matching blocks first, then delete them all back- + to-front (to preserve line indices). Response now includes `deleted_count`. + +- `templates/*.kicad_sch`, `project.py`, `schematic.py`: Update KiCAD schematic format + version from `20230121` (KiCAD 7) to `20250114` (KiCAD 9). The MCP server targets + KiCAD 9 exclusively (`pcbnew.pyd` compiled for KiCAD 9.0, Python 3.11.5) – generating + files in an outdated format caused a spurious "This file was created with an older + KiCAD version" warning on every newly created schematic. + +- `template_with_symbols_expanded.kicad_sch`: Remove 13 corrupt `_TEMPLATE_*` placed-symbol + blocks with `(lib_id -100)` – an integer caused by old sexpdata serializer (same bug + PR #40 fixed for the add path). KiCAD crashed with a null-pointer when selecting these + symbols. They appeared as grey `_TEMPLATE_R?`, `_TEMPLATE_U_REG?` etc. labels far + outside the sheet boundary (~5000mm off-sheet). + + **Discovered via:** live testing on a real JLCPCB/KiCAD 9 project. + **Affected users:** schematics created from this template before this fix contain the + same corrupt blocks – remove all `(symbol (lib_id -100) ...)` blocks whose Reference + starts with `_TEMPLATE_`. + +--- + +--- + ## [2.2.0-alpha] - 2026-02-27 ### New MCP Tools (TypeScript layer – previously Python-only) @@ -36,7 +77,7 @@ All notable changes to the KiCAD MCP Server project are documented here. - `library.py`: Fix loop variable shadowing `Path` object (mypy) - `design_rules.py`: Add type annotation for `violation_counts` (mypy) -### Pending additions (not yet committed) +### New MCP Tools (cont.) **Datasheet tools:** - `get_datasheet_url` - Return LCSC datasheet PDF URL and product page URL for a given @@ -53,7 +94,7 @@ All notable changes to the KiCAD MCP Server project are documented here. - `delete_schematic_component` - Remove a placed symbol from a `.kicad_sch` file by reference designator (e.g. `R1`, `U3`). -### Bug Fixes (pending) +### Bug Fixes (cont.) - `schematic.ts` / `kicad_interface.py`: Fix missing `delete_schematic_component` MCP tool. @@ -75,7 +116,7 @@ All notable changes to the KiCAD MCP Server project are documented here. - Error message explicitly guides the user when the wrong tool is used: *"note: this tool removes schematic symbols, use delete_component for PCB footprints"* -### Pending fixes (not yet committed) +### Additional Bug Fixes - `connection_schematic.py` / `kicad_interface.py`: Fix `generate_netlist` missing `schematic_path` parameter – without it `get_net_connections` always fell back to diff --git a/python/commands/dynamic_symbol_loader.py b/python/commands/dynamic_symbol_loader.py index b06f230..dcf4373 100644 --- a/python/commands/dynamic_symbol_loader.py +++ b/python/commands/dynamic_symbol_loader.py @@ -14,7 +14,7 @@ import logging from pathlib import Path from typing import Dict, List, Optional, Tuple -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") class DynamicSymbolLoader: @@ -43,7 +43,7 @@ class DynamicSymbolLoader: Path.home() / ".local" / "share" / "kicad" / "9.0" / "symbols", Path.home() / "Documents" / "KiCad" / "9.0" / "3rdparty" / "symbols", ] - for env_var in ['KICAD9_SYMBOL_DIR', 'KICAD8_SYMBOL_DIR', 'KICAD_SYMBOL_DIR']: + for env_var in ["KICAD9_SYMBOL_DIR", "KICAD8_SYMBOL_DIR", "KICAD_SYMBOL_DIR"]: if env_var in os.environ: possible_paths.insert(0, Path(os.environ[env_var])) @@ -63,13 +63,14 @@ class DynamicSymbolLoader: Extract a complete symbol block from a library or schematic file by matching parentheses depth. Returns the raw text of the symbol definition. """ - lines = text.split('\n') + lines = text.split("\n") start = None for i, line in enumerate(lines): stripped = line.strip() # Match exact symbol name (not sub-symbols like Name_0_1) - if stripped.startswith(f'(symbol "{symbol_name}"') and \ - not re.match(r'.*_\d+_\d+"', stripped): + if stripped.startswith(f'(symbol "{symbol_name}"') and not re.match( + r'.*_\d+_\d+"', stripped + ): start = i break @@ -80,9 +81,9 @@ class DynamicSymbolLoader: end = None for i in range(start, len(lines)): for ch in lines[i]: - if ch == '(': + if ch == "(": depth += 1 - elif ch == ')': + elif ch == ")": depth -= 1 if depth == 0: end = i @@ -93,9 +94,11 @@ class DynamicSymbolLoader: if end is None: return None - return '\n'.join(lines[start:end + 1]) + return "\n".join(lines[start : end + 1]) - def extract_symbol_from_library(self, library_name: str, symbol_name: str) -> Optional[str]: + def extract_symbol_from_library( + self, library_name: str, symbol_name: str + ) -> Optional[str]: """ Extract a symbol definition from a KiCad .kicad_sym library file. Returns the raw text block, ready to be injected into a schematic. @@ -112,12 +115,14 @@ class DynamicSymbolLoader: if not lib_path: return None - with open(lib_path, 'r', encoding='utf-8') as f: + with open(lib_path, "r", encoding="utf-8") as f: lib_content = f.read() block = self._extract_symbol_block(lib_content, symbol_name) if block is None: - logger.warning(f"Symbol '{symbol_name}' not found in {library_name}.kicad_sym") + logger.warning( + f"Symbol '{symbol_name}' not found in {library_name}.kicad_sym" + ) return None # Check if this symbol uses (extends "ParentName") @@ -125,14 +130,16 @@ class DynamicSymbolLoader: parent_block = None if extends_match: parent_name = extends_match.group(1) - logger.info(f"Symbol {symbol_name} extends {parent_name}, extracting parent too") + logger.info( + f"Symbol {symbol_name} extends {parent_name}, extracting parent too" + ) parent_block = self._extract_symbol_block(lib_content, parent_name) if parent_block: # Prefix parent top-level name with library parent_block = parent_block.replace( f'(symbol "{parent_name}"', f'(symbol "{library_name}:{parent_name}"', - 1 # Only first occurrence (top-level) + 1, # Only first occurrence (top-level) ) # Prefix top-level symbol name with library @@ -140,13 +147,13 @@ class DynamicSymbolLoader: block = block.replace( f'(symbol "{symbol_name}"', f'(symbol "{full_name}"', - 1 # Only first occurrence (top-level) + 1, # Only first occurrence (top-level) ) # Sub-symbols like "Name_0_1" keep their short names (already correct from library) # Combine parent + child if extends is used if parent_block: - result = parent_block + '\n' + block + result = parent_block + "\n" + block else: result = block @@ -154,14 +161,16 @@ class DynamicSymbolLoader: logger.info(f"Extracted symbol {full_name} ({len(result)} chars)") return result - def inject_symbol_into_schematic(self, schematic_path: Path, library_name: str, symbol_name: str) -> bool: + def inject_symbol_into_schematic( + self, schematic_path: Path, library_name: str, symbol_name: str + ) -> bool: """ Inject a symbol definition into a schematic's lib_symbols section. Uses text manipulation to preserve file formatting. """ full_name = f"{library_name}:{symbol_name}" - with open(schematic_path, 'r', encoding='utf-8') as f: + with open(schematic_path, "r", encoding="utf-8") as f: content = f.read() # Check if symbol already exists @@ -172,36 +181,38 @@ class DynamicSymbolLoader: # Extract symbol from library symbol_block = self.extract_symbol_from_library(library_name, symbol_name) if not symbol_block: - raise ValueError(f"Symbol '{symbol_name}' not found in library '{library_name}'") + raise ValueError( + f"Symbol '{symbol_name}' not found in library '{library_name}'" + ) # Indent the block to match lib_symbols indentation (4 spaces for top-level) indented_lines = [] - for line in symbol_block.split('\n'): + for line in symbol_block.split("\n"): # Add 4-space indent for the content inside lib_symbols - indented_lines.append(' ' + line if line.strip() else line) - indented_block = '\n'.join(indented_lines) + indented_lines.append(" " + line if line.strip() else line) + indented_block = "\n".join(indented_lines) # Find the end of lib_symbols section to insert before closing ) - lines = content.split('\n') + lines = content.split("\n") lib_sym_start = None lib_sym_end = None depth = 0 for i, line in enumerate(lines): - if '(lib_symbols' in line and lib_sym_start is None: + if "(lib_symbols" in line and lib_sym_start is None: lib_sym_start = i depth = 0 for ch in line: - if ch == '(': + if ch == "(": depth += 1 - elif ch == ')': + elif ch == ")": depth -= 1 continue if lib_sym_start is not None and lib_sym_end is None: for ch in line: - if ch == '(': + if ch == "(": depth += 1 - elif ch == ')': + elif ch == ")": depth -= 1 if depth == 0: lib_sym_end = i @@ -215,15 +226,29 @@ class DynamicSymbolLoader: # Insert the symbol block just before the closing ) of lib_symbols lines.insert(lib_sym_end, indented_block) - with open(schematic_path, 'w', encoding='utf-8') as f: - f.write('\n'.join(lines)) + with open(schematic_path, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) - logger.info(f"Injected symbol {full_name} into {schematic_path.name}") + # Handle both Path objects and strings + sch_name = ( + schematic_path.name + if hasattr(schematic_path, "name") + else str(schematic_path) + ) + logger.info(f"Injected symbol {full_name} into {sch_name}") return True - def create_component_instance(self, schematic_path: Path, library_name: str, - symbol_name: str, reference: str, - value: str = "", x: float = 0, y: float = 0) -> bool: + def create_component_instance( + self, + schematic_path: Path, + library_name: str, + symbol_name: str, + reference: str, + value: str = "", + footprint: str = "", + x: float = 0, + y: float = 0, + ) -> bool: """ Add a component instance to the schematic. This creates the (symbol ...) block with lib_id reference. @@ -231,7 +256,7 @@ class DynamicSymbolLoader: full_lib_id = f"{library_name}:{symbol_name}" new_uuid = str(uuid.uuid4()) - instance_block = f''' (symbol (lib_id "{full_lib_id}") (at {x} {y} 0) (unit 1) + instance_block = f""" (symbol (lib_id "{full_lib_id}") (at {x} {y} 0) (unit 1) (in_bom yes) (on_board yes) (dnp no) (uuid "{new_uuid}") (property "Reference" "{reference}" (at {x} {y - 2.54} 0) @@ -240,30 +265,30 @@ class DynamicSymbolLoader: (property "Value" "{value or symbol_name}" (at {x} {y + 2.54} 0) (effects (font (size 1.27 1.27))) ) - (property "Footprint" "" (at {x} {y} 0) + (property "Footprint" "{footprint}" (at {x} {y} 0) (effects (font (size 1.27 1.27)) (hide yes)) ) (property "Datasheet" "~" (at {x} {y} 0) (effects (font (size 1.27 1.27)) (hide yes)) ) - )''' + )""" - with open(schematic_path, 'r', encoding='utf-8') as f: + with open(schematic_path, "r", encoding="utf-8") as f: content = f.read() # Insert before (sheet_instances or at end before final ) - lines = content.split('\n') + lines = content.split("\n") insert_pos = None for i, line in enumerate(lines): - if '(sheet_instances' in line: + if "(sheet_instances" in line: insert_pos = i break if insert_pos is None: # Insert before the last closing parenthesis for i in range(len(lines) - 1, -1, -1): - if lines[i].strip() == ')': + if lines[i].strip() == ")": insert_pos = i break @@ -272,13 +297,17 @@ class DynamicSymbolLoader: lines.insert(insert_pos, instance_block) - with open(schematic_path, 'w', encoding='utf-8') as f: - f.write('\n'.join(lines)) + with open(schematic_path, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) - logger.info(f"Added component instance {reference} ({full_lib_id}) at ({x}, {y})") + logger.info( + f"Added component instance {reference} ({full_lib_id}) at ({x}, {y})" + ) return True - def load_symbol_dynamically(self, schematic_path: Path, library_name: str, symbol_name: str) -> str: + def load_symbol_dynamically( + self, schematic_path: Path, library_name: str, symbol_name: str + ) -> str: """ Complete workflow: inject symbol definition and create a template instance. Returns a template reference name. @@ -289,21 +318,34 @@ class DynamicSymbolLoader: self.inject_symbol_into_schematic(schematic_path, library_name, symbol_name) # Step 2: Create an offscreen template instance - lib_clean = library_name.replace('-', '_').replace('.', '_') - sym_clean = symbol_name.replace('-', '_').replace('.', '_') + lib_clean = library_name.replace("-", "_").replace(".", "_") + sym_clean = symbol_name.replace("-", "_").replace(".", "_") template_ref = f"_TEMPLATE_{lib_clean}_{sym_clean}" self.create_component_instance( - schematic_path, library_name, symbol_name, - reference=template_ref, value=symbol_name, - x=-200, y=-200 + schematic_path, + library_name, + symbol_name, + reference=template_ref, + value=symbol_name, + x=-200, + y=-200, ) logger.info(f"Symbol loaded. Template reference: {template_ref}") return template_ref - def add_component(self, schematic_path: Path, library_name: str, symbol_name: str, - reference: str, value: str = "", x: float = 0, y: float = 0) -> bool: + def add_component( + self, + schematic_path: Path, + library_name: str, + symbol_name: str, + reference: str, + value: str = "", + footprint: str = "", + x: float = 0, + y: float = 0, + ) -> bool: """ High-level: ensure symbol definition exists in schematic, then add an instance. This is the main entry point for adding components. @@ -313,12 +355,18 @@ class DynamicSymbolLoader: # Add the component instance return self.create_component_instance( - schematic_path, library_name, symbol_name, - reference=reference, value=value, x=x, y=y + schematic_path, + library_name, + symbol_name, + reference=reference, + value=value, + footprint=footprint, + x=x, + y=y, ) -if __name__ == '__main__': +if __name__ == "__main__": logging.basicConfig(level=logging.INFO) loader = DynamicSymbolLoader() @@ -329,7 +377,12 @@ if __name__ == '__main__': print(f" Found {len(lib_dirs)} directories") print("\n2. Extracting symbols...") - for lib, sym in [('Device', 'R'), ('Device', 'C'), ('Device', 'LED'), ('Device', 'Q_NMOS')]: + for lib, sym in [ + ("Device", "R"), + ("Device", "C"), + ("Device", "LED"), + ("Device", "Q_NMOS"), + ]: block = loader.extract_symbol_from_library(lib, sym) if block: print(f" OK: {lib}:{sym} ({len(block)} chars)") @@ -337,8 +390,8 @@ if __name__ == '__main__': print(f" FAIL: {lib}:{sym}") print("\n3. Testing extends resolution...") - block = loader.extract_symbol_from_library('Regulator_Switching', 'LM2596S-5') - if block and 'LM2596S-12' in block: + block = loader.extract_symbol_from_library("Regulator_Switching", "LM2596S-5") + if block and "LM2596S-12" in block: print(f" OK: LM2596S-5 includes parent LM2596S-12 ({len(block)} chars)") else: print(f" FAIL: extends not resolved") diff --git a/python/commands/footprint.py b/python/commands/footprint.py new file mode 100644 index 0000000..97777d9 --- /dev/null +++ b/python/commands/footprint.py @@ -0,0 +1,506 @@ +""" +Footprint Creator for KiCAD MCP Server + +Creates and edits .kicad_mod footprint files using raw text/S-Expression generation. +Supports THT and SMD pads, courtyard, silkscreen, and fab layer graphics. + +KiCAD 9 .kicad_mod format reference: + https://dev-docs.kicad.org/en/file-formats/sexpr-footprint/ +""" + +import os +import re +import logging +from pathlib import Path +from typing import Any, Dict, List, Optional + +logger = logging.getLogger("kicad_interface") + +KICAD9_FORMAT_VERSION = "20250114" # .kicad_sch schematic files +KICAD9_FOOTPRINT_VERSION = "20241229" # .kicad_mod footprint files + + +def _fmt(v: float) -> str: + """Format a float without unnecessary trailing zeros.""" + return f"{v:g}" + + +class FootprintCreator: + """ + Creates and edits KiCAD .kicad_mod footprint files via text generation. + No sexpdata – pure f-string assembly to guarantee format correctness. + """ + + # ------------------------------------------------------------------ # + # Public API # + # ------------------------------------------------------------------ # + + def create_footprint( + self, + library_path: str, + name: str, + description: str = "", + tags: str = "", + pads: Optional[List[Dict[str, Any]]] = None, + courtyard: Optional[Dict[str, Any]] = None, + silkscreen: Optional[Dict[str, Any]] = None, + fab_layer: Optional[Dict[str, Any]] = None, + ref_position: Optional[Dict[str, float]] = None, + value_position: Optional[Dict[str, float]] = None, + overwrite: bool = False, + ) -> Dict[str, Any]: + """ + Create a new .kicad_mod footprint file. + + Parameters + ---------- + library_path : str + Path to the .pretty directory (created if missing). + name : str + Footprint name, e.g. "R_0603_Custom". + description : str + Human-readable description. + tags : str + Space-separated tag string. + pads : list of dicts + Each pad dict supports: + number (str) – pad number / net name, e.g. "1" + type (str) – "smd" | "thru_hole" | "np_thru_hole" + shape (str) – "rect" | "circle" | "oval" | "roundrect" + at (dict) – {"x": float, "y": float, "angle": float (opt)} + size (dict) – {"w": float, "h": float} + drill (float or dict) – scalar for round drill, dict for oval: + {"w": float, "h": float} + layers (list) – override default layer list + roundrect_ratio (float) – 0.0..0.5 for roundrect shape + courtyard : dict or None + {"x1": float, "y1": float, "x2": float, "y2": float, "width": float} + silkscreen : dict or None + {"x1": float, "y1": float, "x2": float, "y2": float, "width": float} + fab_layer : dict or None + {"x1": float, "y1": float, "x2": float, "y2": float, "width": float} + ref_position : dict or None – {"x": float, "y": float} + value_position : dict or None – {"x": float, "y": float} + overwrite : bool + If False (default), raise if file already exists. + + Returns + ------- + dict with "success", "path", "pad_count" + """ + lib = Path(library_path) + if not lib.suffix == ".pretty": + lib = lib.with_suffix(".pretty") + lib.mkdir(parents=True, exist_ok=True) + + mod_path = lib / f"{name}.kicad_mod" + if mod_path.exists() and not overwrite: + return { + "success": False, + "error": f"Footprint already exists: {mod_path}. Use overwrite=true to replace.", + "path": str(mod_path), + } + + pads = pads or [] + lines: List[str] = [] + + # ---- header ---- + lines.append(f'(footprint "{name}"') + lines.append(f' (version {KICAD9_FOOTPRINT_VERSION})') + lines.append(f' (generator "kicad-mcp")') + lines.append(f' (generator_version "9.0")') + lines.append(f' (layer "F.Cu")') + if description: + lines.append(f' (descr "{_esc(description)}")') + if tags: + lines.append(f' (tags "{_esc(tags)}")') + lines.append("") + + # ---- reference / value text ---- + ref_x = ref_position.get("x", 0.0) if ref_position else 0.0 + ref_y = ref_position.get("y", -1.27) if ref_position else -1.27 + val_x = value_position.get("x", 0.0) if value_position else 0.0 + val_y = value_position.get("y", 1.27) if value_position else 1.27 + + lines.append( + f' (property "Reference" "REF**" (at {_fmt(ref_x)} {_fmt(ref_y)} 0)' + ) + lines.append(f' (layer "F.SilkS")') + lines.append(f' (uuid "{_new_uuid()}")') + lines.append(f' (effects (font (size 1 1) (thickness 0.15)))') + lines.append(f' )') + lines.append( + f' (property "Value" "{_esc(name)}" (at {_fmt(val_x)} {_fmt(val_y)} 0)' + ) + lines.append(f' (layer "F.Fab")') + lines.append(f' (uuid "{_new_uuid()}")') + lines.append(f' (effects (font (size 1 1) (thickness 0.15)))') + lines.append(f' )') + lines.append(f' (property "Datasheet" "" (at 0 0 0)') + lines.append(f' (layer "F.Fab")') + lines.append(f' (uuid "{_new_uuid()}")') + lines.append(f' (effects (font (size 1 1) (thickness 0.15)))') + lines.append(f' )') + lines.append("") + + # ---- courtyard ---- + if courtyard: + lines.extend(_rect_lines(courtyard, "F.CrtYd", default_width=0.05)) + + # ---- silkscreen ---- + if silkscreen: + lines.extend(_rect_lines(silkscreen, "F.SilkS", default_width=0.12)) + + # ---- fab layer ---- + if fab_layer: + lines.extend(_rect_lines(fab_layer, "F.Fab", default_width=0.1)) + + # ---- pads ---- + for pad in pads: + lines.extend(_pad_lines(pad)) + lines.append("") + + lines.append(")") + + content = "\n".join(lines) + "\n" + mod_path.write_text(content, encoding="utf-8") + logger.info(f"Created footprint: {mod_path} ({len(pads)} pads)") + + return { + "success": True, + "path": str(mod_path), + "name": name, + "pad_count": len(pads), + } + + def edit_footprint_pad( + self, + footprint_path: str, + pad_number: str, + size: Optional[Dict[str, float]] = None, + at: Optional[Dict[str, float]] = None, + drill: Optional[Any] = None, + shape: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Edit an existing pad in a .kicad_mod file. + + Parameters + ---------- + footprint_path : str + Full path to the .kicad_mod file. + pad_number : str + Pad number to update (e.g. "1", "2"). + size : dict or None – {"w": float, "h": float} + at : dict or None – {"x": float, "y": float, "angle": float (opt)} + drill : float or dict or None + shape : str or None – "rect" | "circle" | "oval" | "roundrect" + + Returns + ------- + dict with "success", "updated", "pad_number" + """ + path = Path(footprint_path) + if not path.exists(): + return {"success": False, "error": f"File not found: {footprint_path}"} + + content = path.read_text(encoding="utf-8") + updated: List[str] = [] + + # Find the pad block for pad_number and apply modifications + # Strategy: locate "(pad """ line and patch individual fields + # We use a simple line-by-line state machine that tracks brace depth + # to stay inside the correct pad block. + + def patch_pad_block(block: str) -> str: + nonlocal updated + changes = [] + if size: + new_size = f'(size {_fmt(size["w"])} {_fmt(size["h"])})' + block, n = re.subn(r'\(size\s+[\d.]+\s+[\d.]+\)', new_size, block) + if n: + changes.append(f"size→{new_size}") + if at: + angle = at.get("angle", 0) + new_at = f'(at {_fmt(at["x"])} {_fmt(at["y"])} {_fmt(angle)})' + block, n = re.subn(r'\(at\s+[-\d.]+\s+[-\d.]+(?:\s+[-\d.]+)?\)', new_at, block) + if n: + changes.append(f"at→{new_at}") + if drill is not None: + if isinstance(drill, (int, float)): + new_drill = f'(drill {_fmt(drill)})' + else: + new_drill = f'(drill oval {_fmt(drill["w"])} {_fmt(drill["h"])})' + block, n = re.subn(r'\(drill(?:\s+oval)?\s+[-\d.]+(?:\s+[-\d.]+)?\)', new_drill, block) + if n: + changes.append(f"drill→{new_drill}") + else: + # Insert drill before closing paren of pad block + block = block.rstrip().rstrip(')') + f'\n {new_drill}\n )' + changes.append(f"drill (inserted)→{new_drill}") + if shape: + block, n = re.subn( + r'(pad\s+"[^"]*"\s+\w+\s+)\w+', + lambda m: m.group(1) + shape, + block, + count=1 + ) + if n: + changes.append(f"shape→{shape}") + updated.extend(changes) + return block + + # Parse blocks + result_lines = [] + in_target_pad = False + pad_depth = 0 + pad_block_lines: List[str] = [] + + for line in content.split("\n"): + stripped = line.strip() + if not in_target_pad: + # Detect start of target pad + if re.match(rf'\(pad\s+"{re.escape(pad_number)}"\s+', stripped): + in_target_pad = True + pad_depth = stripped.count("(") - stripped.count(")") + pad_block_lines = [line] + else: + result_lines.append(line) + else: + pad_block_lines.append(line) + pad_depth += stripped.count("(") - stripped.count(")") + if pad_depth <= 0: + # End of pad block – patch and flush + block = "\n".join(pad_block_lines) + block = patch_pad_block(block) + result_lines.extend(block.split("\n")) + in_target_pad = False + pad_block_lines = [] + + if not updated: + return { + "success": False, + "error": f"Pad \"{pad_number}\" not found or no changes made in {footprint_path}", + } + + path.write_text("\n".join(result_lines), encoding="utf-8") + logger.info(f"Edited pad {pad_number} in {path.name}: {updated}") + + return { + "success": True, + "footprint_path": str(path), + "pad_number": pad_number, + "updated": updated, + } + + def list_footprint_libraries(self, search_paths: Optional[List[str]] = None) -> Dict[str, Any]: + """List all .pretty libraries and their footprints.""" + default_paths = [ + r"C:\Program Files\KiCad\9.0\share\kicad\footprints", + r"C:\Program Files\KiCad\8.0\share\kicad\footprints", + "/usr/share/kicad/footprints", + "/usr/local/share/kicad/footprints", + os.path.expanduser("~/Documents/KiCad/9.0/footprints"), + ] + paths = search_paths or default_paths + libraries = {} + for base in paths: + bp = Path(base) + if not bp.exists(): + continue + for pretty in sorted(bp.glob("*.pretty")): + name = pretty.stem + mods = sorted(p.stem for p in pretty.glob("*.kicad_mod")) + libraries[name] = {"path": str(pretty), "count": len(mods), "footprints": mods[:20]} + return {"success": True, "library_count": len(libraries), "libraries": libraries} + + def register_footprint_library( + self, + library_path: str, + library_name: Optional[str] = None, + description: str = "", + scope: str = "project", + project_path: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Register a .pretty library in KiCAD's fp-lib-table so KiCAD can find it. + + Parameters + ---------- + library_path : str + Full path to the .pretty directory. + library_name : str or None + Nickname for the library (default: directory stem). + description : str + Optional description string. + scope : str + "project" (writes fp-lib-table next to .kicad_pro) or + "global" (writes to ~/.config/kicad/9.0/fp-lib-table). + project_path : str or None + Path to the .kicad_pro file or its directory (needed for scope="project"). + + Returns + ------- + dict with "success", "table_path", "library_name", "already_registered" + """ + pretty = Path(library_path) + if not pretty.suffix == ".pretty": + pretty = pretty.with_suffix(".pretty") + + name = library_name or pretty.stem + uri = str(pretty).replace("\\", "/") # KiCAD prefers forward slashes + + # Resolve fp-lib-table path + if scope == "project": + if project_path: + proj = Path(project_path) + table_dir = proj if proj.is_dir() else proj.parent + else: + # Default: same directory as the .pretty library + table_dir = pretty.parent + table_path = table_dir / "fp-lib-table" + else: # global + cfg_dirs = [ + Path(os.environ.get("APPDATA", "")) / "kicad" / "9.0", + Path.home() / ".config" / "kicad" / "9.0", + Path.home() / ".local" / "share" / "kicad" / "9.0", + ] + table_path = None + for d in cfg_dirs: + candidate = d / "fp-lib-table" + if candidate.exists(): + table_path = candidate + break + if table_path is None: + # Create in first writable config dir + for d in cfg_dirs: + try: + d.mkdir(parents=True, exist_ok=True) + table_path = d / "fp-lib-table" + break + except OSError: + continue + if table_path is None: + return {"success": False, "error": "Could not find or create global fp-lib-table"} + + # Read existing table or start fresh + if table_path.exists(): + content = table_path.read_text(encoding="utf-8") + else: + content = "(fp_lib_table\n (version 7)\n)\n" + + # Check if already registered (by name OR by uri) + if f'(name "{name}")' in content or uri in content: + return { + "success": True, + "already_registered": True, + "table_path": str(table_path), + "library_name": name, + } + + # Insert new lib entry before closing paren + new_entry = ( + f' (lib (name "{name}")' + f'(type "KiCad")' + f'(uri "{uri}")' + f'(options "")' + f'(descr "{_esc(description)}"))' + ) + # Insert before the last closing paren + content = content.rstrip() + if content.endswith(")"): + content = content[:-1].rstrip() + "\n" + new_entry + "\n)\n" + else: + content += "\n" + new_entry + "\n)\n" + + table_path.write_text(content, encoding="utf-8") + logger.info(f"Registered library '{name}' in {table_path}") + + return { + "success": True, + "already_registered": False, + "table_path": str(table_path), + "library_name": name, + "uri": uri, + } + + +# ------------------------------------------------------------------ # +# Internal helpers # +# ------------------------------------------------------------------ # + +def _esc(s: str) -> str: + """Escape double-quotes inside S-Expression string values.""" + return s.replace('"', '\\"') + + +def _new_uuid() -> str: + import uuid + return str(uuid.uuid4()) + + +_DEFAULT_SMD_LAYERS = ["F.Cu", "F.Paste", "F.Mask"] +_DEFAULT_THT_LAYERS = ["*.Cu", "*.Mask"] + + +def _pad_lines(pad: Dict[str, Any]) -> List[str]: + number = str(pad.get("number", "1")) + ptype = pad.get("type", "smd").lower() # smd | thru_hole | np_thru_hole + shape = pad.get("shape", "rect").lower() # rect | circle | oval | roundrect + at = pad.get("at", {"x": 0.0, "y": 0.0}) + size = pad.get("size", {"w": 1.0, "h": 1.0}) + drill = pad.get("drill", None) + layers = pad.get("layers", None) + rr_ratio = pad.get("roundrect_ratio", 0.25) + + ax = _fmt(at.get("x", 0.0)) + ay = _fmt(at.get("y", 0.0)) + aangle = at.get("angle", None) + at_str = f"(at {ax} {ay})" if aangle is None else f"(at {ax} {ay} {_fmt(aangle)})" + + sw = _fmt(size.get("w", 1.0)) + sh = _fmt(size.get("h", 1.0)) + + if layers is None: + layers = _DEFAULT_THT_LAYERS if ptype in ("thru_hole", "np_thru_hole") else _DEFAULT_SMD_LAYERS + layers_str = " ".join(f'"{l}"' for l in layers) + + lines = [f' (pad "{number}" {ptype} {shape}'] + lines.append(f" {at_str}") + lines.append(f" (size {sw} {sh})") + + if drill is not None: + if isinstance(drill, (int, float)): + lines.append(f" (drill {_fmt(drill)})") + elif isinstance(drill, dict): + dw = _fmt(drill.get("w", 1.0)) + dh = _fmt(drill.get("h", 1.0)) + lines.append(f" (drill oval {dw} {dh})") + + lines.append(f" (layers {layers_str})") + + if shape == "roundrect": + lines.append(f" (roundrect_rratio {_fmt(rr_ratio)})") + + lines.append(f' (uuid "{_new_uuid()}")') + lines.append(f" )") + return lines + + +def _rect_lines(rect: Dict[str, Any], layer: str, default_width: float = 0.05) -> List[str]: + x1 = _fmt(rect.get("x1", -1.0)) + y1 = _fmt(rect.get("y1", -1.0)) + x2 = _fmt(rect.get("x2", 1.0)) + y2 = _fmt(rect.get("y2", 1.0)) + w = _fmt(rect.get("width", default_width)) + return [ + f' (fp_rect', + f' (start {x1} {y1})', + f' (end {x2} {y2})', + f' (stroke (width {w}) (type default))', + f' (fill none)', + f' (layer "{layer}")', + f' (uuid "{_new_uuid()}")', + f' )', + "", + ] diff --git a/python/commands/project.py b/python/commands/project.py index 7505647..cb710e0 100644 --- a/python/commands/project.py +++ b/python/commands/project.py @@ -8,7 +8,8 @@ import logging import shutil from typing import Dict, Any, Optional -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") + class ProjectCommands: """Handles project-related KiCAD operations""" @@ -21,7 +22,9 @@ class ProjectCommands: """Create a new KiCAD project""" try: # Accept both 'name' (from MCP tool) and 'projectName' (legacy) - project_name = params.get("name") or params.get("projectName", "New_Project") + project_name = params.get("name") or params.get( + "projectName", "New_Project" + ) path = params.get("path", os.getcwd()) template = params.get("template") @@ -35,12 +38,13 @@ class ProjectCommands: # Create a new board board = pcbnew.BOARD() - + # Set project properties board.GetTitleBlock().SetTitle(project_name) - + # Set current date with proper parameter from datetime import datetime + current_date = datetime.now().strftime("%Y-%m-%d") board.GetTitleBlock().SetDate(current_date) @@ -58,38 +62,58 @@ class ProjectCommands: board.SetFileName(board_path) pcbnew.SaveBoard(board_path, board) - # Create schematic from template (use expanded template with many component types) + # Create schematic from template (use expanded template with symbol definitions) schematic_path = project_path.replace(".kicad_pro", ".kicad_sch") template_sch_path = os.path.join( os.path.dirname(os.path.abspath(__file__)), - '..', 'templates', 'template_with_symbols_expanded.kicad_sch' + "..", + "templates", + "template_with_symbols_expanded.kicad_sch", ) if os.path.exists(template_sch_path): # Copy template schematic shutil.copy(template_sch_path, schematic_path) + + # Replace placeholder UUID with a real one + import uuid as uuid_module + + with open(schematic_path, "r", encoding="utf-8") as f: + sch_content = f.read() + sch_content = sch_content.replace( + "00000000-0000-0000-0000-000000000000", str(uuid_module.uuid4()) + ) + with open(schematic_path, "w", encoding="utf-8") as f: + f.write(sch_content) + logger.info(f"Created schematic from template: {schematic_path}") else: # Fallback: create minimal schematic - logger.warning(f"Template not found at {template_sch_path}, creating minimal schematic") - with open(schematic_path, 'w') as f: - f.write(f'(kicad_sch (version 20230121) (generator "KiCAD-MCP-Server")\n\n') - f.write(f' (uuid 00000000-0000-0000-0000-000000000000)\n\n') + logger.warning( + f"Template not found at {template_sch_path}, creating minimal schematic" + ) + import uuid as uuid_module + + with open(schematic_path, "w") as f: + f.write( + f'(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n' + ) + f.write(f" (uuid {str(uuid_module.uuid4())})\n\n") f.write(f' (paper "A4")\n\n') - f.write(f' (lib_symbols\n )\n\n') + f.write(f" (lib_symbols\n )\n\n") f.write(f' (sheet_instances\n (path "/" (page "1"))\n )\n') - f.write(f')\n') + f.write(f")\n") # Create project file with schematic reference - with open(project_path, 'w') as f: - f.write('{\n') + with open(project_path, "w") as f: + f.write("{\n") f.write(' "board": {\n') f.write(f' "filename": "{os.path.basename(board_path)}"\n') - f.write(' },\n') + f.write(" },\n") f.write(' "sheets": [\n') f.write(f' ["root", "{os.path.basename(schematic_path)}"]\n') - f.write(' ]\n') - f.write('}\n') + f.write(" ]\n") + f.write("}\n") self.board = board @@ -100,8 +124,8 @@ class ProjectCommands: "name": project_name, "path": project_path, "boardPath": board_path, - "schematicPath": schematic_path - } + "schematicPath": schematic_path, + }, } except Exception as e: @@ -109,7 +133,7 @@ class ProjectCommands: return { "success": False, "message": "Failed to create project", - "errorDetails": str(e) + "errorDetails": str(e), } def open_project(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -120,7 +144,7 @@ class ProjectCommands: return { "success": False, "message": "No filename provided", - "errorDetails": "The filename parameter is required" + "errorDetails": "The filename parameter is required", } # Expand user path and make absolute @@ -142,8 +166,8 @@ class ProjectCommands: "project": { "name": os.path.splitext(os.path.basename(board_path))[0], "path": filename, - "boardPath": board_path - } + "boardPath": board_path, + }, } except Exception as e: @@ -151,7 +175,7 @@ class ProjectCommands: return { "success": False, "message": "Failed to open project", - "errorDetails": str(e) + "errorDetails": str(e), } def save_project(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -161,7 +185,7 @@ class ProjectCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } filename = params.get("filename") @@ -177,9 +201,11 @@ class ProjectCommands: "success": True, "message": f"Saved project to: {self.board.GetFileName()}", "project": { - "name": os.path.splitext(os.path.basename(self.board.GetFileName()))[0], - "path": self.board.GetFileName() - } + "name": os.path.splitext( + os.path.basename(self.board.GetFileName()) + )[0], + "path": self.board.GetFileName(), + }, } except Exception as e: @@ -187,7 +213,7 @@ class ProjectCommands: return { "success": False, "message": "Failed to save project", - "errorDetails": str(e) + "errorDetails": str(e), } def get_project_info(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -197,12 +223,12 @@ class ProjectCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } title_block = self.board.GetTitleBlock() filename = self.board.GetFileName() - + return { "success": True, "project": { @@ -215,8 +241,8 @@ class ProjectCommands: "comment1": title_block.GetComment(0), "comment2": title_block.GetComment(1), "comment3": title_block.GetComment(2), - "comment4": title_block.GetComment(3) - } + "comment4": title_block.GetComment(3), + }, } except Exception as e: @@ -224,5 +250,5 @@ class ProjectCommands: return { "success": False, "message": "Failed to get project information", - "errorDetails": str(e) + "errorDetails": str(e), } diff --git a/python/commands/schematic.py b/python/commands/schematic.py index cc97629..b945ea7 100644 --- a/python/commands/schematic.py +++ b/python/commands/schematic.py @@ -4,7 +4,8 @@ import shutil import logging import uuid -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") + class SchematicManager: """Core schematic operations using kicad-skip""" @@ -16,11 +17,13 @@ class SchematicManager: # Determine template path (use template_with_symbols for component cloning support) template_path = os.path.join( os.path.dirname(os.path.abspath(__file__)), - '..', 'templates', 'template_with_symbols.kicad_sch' + "..", + "templates", + "template_with_symbols.kicad_sch", ) # Determine output path - output_path = name if name.endswith('.kicad_sch') else f"{name}.kicad_sch" + output_path = name if name.endswith(".kicad_sch") else f"{name}.kicad_sch" if os.path.exists(template_path): # Copy template to target location @@ -28,17 +31,21 @@ class SchematicManager: logger.info(f"Created schematic from template: {output_path}") else: # Fallback: create minimal schematic - logger.warning(f"Template not found at {template_path}, creating minimal schematic") + logger.warning( + f"Template not found at {template_path}, creating minimal schematic" + ) # Generate unique UUID for this schematic schematic_uuid = str(uuid.uuid4()) # Write with explicit UTF-8 encoding and Unix line endings for cross-platform compatibility - with open(output_path, 'w', encoding='utf-8', newline='\n') as f: - f.write('(kicad_sch (version 20230121) (generator "KiCAD-MCP-Server")\n\n') - f.write(f' (uuid {schematic_uuid})\n\n') + with open(output_path, "w", encoding="utf-8", newline="\n") as f: + f.write( + '(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n' + ) + f.write(f" (uuid {schematic_uuid})\n\n") f.write(' (paper "A4")\n\n') - f.write(' (lib_symbols\n )\n\n') + f.write(" (lib_symbols\n )\n\n") f.write(' (sheet_instances\n (path "/" (page "1"))\n )\n') - f.write(')\n') + f.write(")\n") # Load the schematic sch = Schematic(output_path) @@ -88,7 +95,8 @@ class SchematicManager: logger.debug("Extracted schematic metadata") return metadata -if __name__ == '__main__': + +if __name__ == "__main__": # Example Usage (for testing) # Create a new schematic new_sch = SchematicManager.create_schematic("MyTestSchematic") diff --git a/python/commands/symbol_creator.py b/python/commands/symbol_creator.py new file mode 100644 index 0000000..a54d239 --- /dev/null +++ b/python/commands/symbol_creator.py @@ -0,0 +1,465 @@ +""" +Symbol Creator for KiCAD MCP Server + +Creates and edits .kicad_sym symbol library files using raw S-Expression text generation. +No sexpdata – pure f-string assembly to guarantee format correctness. + +KiCAD 9 .kicad_sym format: + - Library file starts with (kicad_symbol_lib (version 20241209) ...) + - Each symbol has a parent block with properties + two sub-symbols: + SymbolName_0_1 → body graphics (rectangle, polyline, circle, arc) + SymbolName_1_1 → pins + - All coordinates in mm, 2.54mm grid typical for schematic symbols +""" + +import os +import re +import logging +from pathlib import Path +from typing import Any, Dict, List, Optional + +logger = logging.getLogger("kicad_interface") + +KICAD9_SYMBOL_LIB_VERSION = "20241209" + +# Pin electrical types +PIN_TYPES = { + "input", "output", "bidirectional", "tri_state", "passive", + "free", "unspecified", "power_in", "power_out", + "open_collector", "open_emitter", "no_connect", +} + +# Pin graphic shapes +PIN_SHAPES = { + "line", "inverted", "clock", "inverted_clock", "input_low", + "clock_low", "output_low", "falling_edge_clock", "non_logic", +} + + +def _fmt(v: float) -> str: + return f"{v:g}" + + +def _esc(s: str) -> str: + return s.replace('"', '\\"') + + +class SymbolCreator: + """Creates and edits KiCAD .kicad_sym symbol library files.""" + + # ------------------------------------------------------------------ # + # create_symbol # + # ------------------------------------------------------------------ # + + def create_symbol( + self, + library_path: str, + name: str, + reference_prefix: str = "U", + description: str = "", + keywords: str = "", + datasheet: str = "~", + footprint: str = "", + in_bom: bool = True, + on_board: bool = True, + pins: Optional[List[Dict[str, Any]]] = None, + rectangles: Optional[List[Dict[str, Any]]] = None, + polylines: Optional[List[Dict[str, Any]]] = None, + overwrite: bool = False, + ) -> Dict[str, Any]: + """ + Add a new symbol to a .kicad_sym library (creates the file if missing). + + Parameters + ---------- + library_path : str + Path to the .kicad_sym file (created if missing). + name : str + Symbol name, e.g. "TMC2209", "MyOpAmp". + reference_prefix : str + Schematic reference prefix, e.g. "U", "R", "J". + description : str + Human-readable description. + keywords : str + Space-separated keyword string for search. + datasheet : str + Datasheet URL or "~". + footprint : str + Default footprint, e.g. "Package_SO:SOIC-8". + in_bom : bool + Include in BOM (default True). + on_board : bool + Include in netlist for PCB (default True). + pins : list of dicts + Each pin dict: + name (str) – pin name, e.g. "VCC", "GND", "~" for unnamed + number (str) – pin number, e.g. "1", "A1" + type (str) – electrical type: input|output|bidirectional| + passive|power_in|power_out|tri_state| + open_collector|open_emitter|free|unspecified + at (dict) – {"x": float, "y": float, "angle": float} + angle: 0=right, 90=up, 180=left, 270=down + length (float) – pin length in mm (default 2.54) + shape (str) – graphic shape: line|inverted|clock|... + (default "line") + rectangles : list of dicts or None + Body rectangles: {"x1","y1","x2","y2", "width"(opt), "fill"(opt)} + fill: "none"|"outline"|"background" (default "background") + polylines : list of dicts or None + {"points": [{"x":float,"y":float},...], "width"(opt), "fill"(opt)} + overwrite : bool + Replace existing symbol with same name (default False). + + Returns + ------- + dict with "success", "library_path", "symbol_name", "pin_count" + """ + lib_path = Path(library_path) + if lib_path.suffix.lower() != ".kicad_sym": + lib_path = lib_path.with_suffix(".kicad_sym") + + lib_path.parent.mkdir(parents=True, exist_ok=True) + + # Load or create library + if lib_path.exists(): + lib_content = lib_path.read_text(encoding="utf-8") + else: + lib_content = ( + f'(kicad_symbol_lib\n' + f' (version {KICAD9_SYMBOL_LIB_VERSION})\n' + f' (generator "kicad-mcp")\n' + f' (generator_version "9.0")\n' + f')\n' + ) + + # Check for duplicate + if f'(symbol "{name}"' in lib_content: + if not overwrite: + return { + "success": False, + "error": f'Symbol "{name}" already exists in {lib_path}. Use overwrite=true.', + "library_path": str(lib_path), + } + lib_content = self._remove_symbol(lib_content, name) + + pins = pins or [] + rectangles = rectangles or [] + polylines = polylines or [] + + symbol_block = self._build_symbol_block( + name=name, + reference_prefix=reference_prefix, + description=description, + keywords=keywords, + datasheet=datasheet, + footprint=footprint, + in_bom=in_bom, + on_board=on_board, + pins=pins, + rectangles=rectangles, + polylines=polylines, + ) + + # Insert before closing paren of library + lib_content = lib_content.rstrip() + if lib_content.endswith(")"): + lib_content = lib_content[:-1].rstrip() + "\n" + symbol_block + "\n)\n" + else: + lib_content += "\n" + symbol_block + "\n)\n" + + lib_path.write_text(lib_content, encoding="utf-8") + logger.info(f"Created symbol '{name}' in {lib_path} ({len(pins)} pins)") + + return { + "success": True, + "library_path": str(lib_path), + "symbol_name": name, + "pin_count": len(pins), + } + + # ------------------------------------------------------------------ # + # delete_symbol # + # ------------------------------------------------------------------ # + + def delete_symbol(self, library_path: str, name: str) -> Dict[str, Any]: + """Remove a symbol from a .kicad_sym library.""" + lib_path = Path(library_path) + if not lib_path.exists(): + return {"success": False, "error": f"Library not found: {library_path}"} + + content = lib_path.read_text(encoding="utf-8") + if f'(symbol "{name}"' not in content: + return {"success": False, "error": f'Symbol "{name}" not found in {library_path}'} + + new_content = self._remove_symbol(content, name) + lib_path.write_text(new_content, encoding="utf-8") + return {"success": True, "library_path": str(lib_path), "deleted": name} + + # ------------------------------------------------------------------ # + # list_symbols (in a single library file) # + # ------------------------------------------------------------------ # + + def list_symbols(self, library_path: str) -> Dict[str, Any]: + """List all symbols in a .kicad_sym file.""" + lib_path = Path(library_path) + if not lib_path.exists(): + return {"success": False, "error": f"Library not found: {library_path}"} + + content = lib_path.read_text(encoding="utf-8") + # Only top-level symbols (not sub-symbols like _0_1 or _1_1) + names = re.findall(r'^\s*\(symbol "([^"_][^"]*)"', content, re.MULTILINE) + # Filter out sub-symbols (contain _N_N suffix) + symbols = [n for n in names if not re.search(r'_\d+_\d+$', n)] + return { + "success": True, + "library_path": str(lib_path), + "symbol_count": len(symbols), + "symbols": symbols, + } + + # ------------------------------------------------------------------ # + # register_symbol_library # + # ------------------------------------------------------------------ # + + def register_symbol_library( + self, + library_path: str, + library_name: Optional[str] = None, + description: str = "", + scope: str = "project", + project_path: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Register a .kicad_sym library in KiCAD's sym-lib-table. + + Parameters + ---------- + library_path : str – path to the .kicad_sym file + library_name : str – nickname (default: file stem) + scope : str – "project" or "global" + project_path : str – .kicad_pro or directory (for scope=project) + """ + sym_path = Path(library_path) + name = library_name or sym_path.stem + uri = str(sym_path).replace("\\", "/") + + if scope == "project": + if project_path: + proj = Path(project_path) + table_dir = proj if proj.is_dir() else proj.parent + else: + table_dir = sym_path.parent + table_path = table_dir / "sym-lib-table" + else: + cfg_dirs = [ + Path(os.environ.get("APPDATA", "")) / "kicad" / "9.0", + Path.home() / ".config" / "kicad" / "9.0", + ] + table_path = None + for d in cfg_dirs: + candidate = d / "sym-lib-table" + if candidate.exists(): + table_path = candidate + break + if table_path is None: + for d in cfg_dirs: + try: + d.mkdir(parents=True, exist_ok=True) + table_path = d / "sym-lib-table" + break + except OSError: + continue + if table_path is None: + return {"success": False, "error": "Could not find/create global sym-lib-table"} + + if table_path.exists(): + content = table_path.read_text(encoding="utf-8") + else: + content = "(sym_lib_table\n (version 7)\n)\n" + + if f'(name "{name}")' in content or uri in content: + return { + "success": True, + "already_registered": True, + "table_path": str(table_path), + "library_name": name, + } + + new_entry = ( + f' (lib (name "{name}")' + f'(type "KiCad")' + f'(uri "{uri}")' + f'(options "")' + f'(descr "{_esc(description)}"))' + ) + content = content.rstrip() + if content.endswith(")"): + content = content[:-1].rstrip() + "\n" + new_entry + "\n)\n" + else: + content += "\n" + new_entry + "\n)\n" + + table_path.write_text(content, encoding="utf-8") + logger.info(f"Registered symbol library '{name}' in {table_path}") + + return { + "success": True, + "already_registered": False, + "table_path": str(table_path), + "library_name": name, + "uri": uri, + } + + # ------------------------------------------------------------------ # + # Internal helpers # + # ------------------------------------------------------------------ # + + def _build_symbol_block( + self, + name: str, + reference_prefix: str, + description: str, + keywords: str, + datasheet: str, + footprint: str, + in_bom: bool, + on_board: bool, + pins: List[Dict[str, Any]], + rectangles: List[Dict[str, Any]], + polylines: List[Dict[str, Any]], + ) -> str: + lines: List[str] = [] + bom_str = "yes" if in_bom else "no" + board_str = "yes" if on_board else "no" + + lines.append(f' (symbol "{name}"') + lines.append(f' (exclude_from_sim no)') + lines.append(f' (in_bom {bom_str})') + lines.append(f' (on_board {board_str})') + + # Properties + lines.extend(_property_block("Reference", reference_prefix, 2.54, 0, visible=True)) + lines.extend(_property_block("Value", name, 0, -2.54, visible=True)) + lines.extend(_property_block("Footprint", footprint, 0, -5.08, visible=False)) + lines.extend(_property_block("Datasheet", datasheet or "~", 0, -7.62, visible=False)) + lines.extend(_property_block("Description", description, 0, -10.16, visible=False)) + if keywords: + lines.extend(_property_block("ki_keywords", keywords, 0, 0, visible=False)) + + # Sub-symbol _0_1: body graphics + lines.append(f' (symbol "{name}_0_1"') + for rect in rectangles: + lines.extend(_rect_sym_lines(rect)) + for pl in polylines: + lines.extend(_polyline_lines(pl)) + lines.append(f' )') + + # Sub-symbol _1_1: pins + lines.append(f' (symbol "{name}_1_1"') + for pin in pins: + lines.extend(_pin_lines(pin)) + lines.append(f' )') + + lines.append(f' )') + return "\n".join(lines) + + def _remove_symbol(self, content: str, name: str) -> str: + """Remove a complete symbol block from library content.""" + lines = content.split("\n") + result = [] + skip = False + depth = 0 + + for line in lines: + stripped = line.strip() + if not skip: + if re.match(rf'^\s*\(symbol "{re.escape(name)}"', line) and \ + not re.search(r'_\d+_\d+"', line): + skip = True + depth = stripped.count("(") - stripped.count(")") + continue + result.append(line) + else: + depth += stripped.count("(") - stripped.count(")") + if depth <= 0: + skip = False + + return "\n".join(result) + + +# ------------------------------------------------------------------ # +# S-Expression helper functions # +# ------------------------------------------------------------------ # + +def _property_block( + key: str, value: str, x: float, y: float, visible: bool = True +) -> List[str]: + hide = "" if visible else "\n (hide yes)" + return [ + f' (property "{_esc(key)}" "{_esc(value)}"', + f' (at {_fmt(x)} {_fmt(y)} 0)', + f' (effects', + f' (font (size 1.27 1.27))', + f' ){hide}', + f' )', + ] + + +def _rect_sym_lines(rect: Dict[str, Any]) -> List[str]: + x1 = _fmt(rect.get("x1", -2.54)) + y1 = _fmt(rect.get("y1", -2.54)) + x2 = _fmt(rect.get("x2", 2.54)) + y2 = _fmt(rect.get("y2", 2.54)) + w = _fmt(rect.get("width", 0.254)) + fill = rect.get("fill", "background") + return [ + f' (rectangle', + f' (start {x1} {y1})', + f' (end {x2} {y2})', + f' (stroke (width {w}) (type default))', + f' (fill (type {fill}))', + f' )', + ] + + +def _polyline_lines(pl: Dict[str, Any]) -> List[str]: + pts = pl.get("points", []) + w = _fmt(pl.get("width", 0.254)) + fill = pl.get("fill", "none") + lines = [ + f' (polyline', + f' (pts', + ] + for pt in pts: + lines.append(f' (xy {_fmt(pt["x"])} {_fmt(pt["y"])})') + lines += [ + f' )', + f' (stroke (width {w}) (type default))', + f' (fill (type {fill}))', + f' )', + ] + return lines + + +def _pin_lines(pin: Dict[str, Any]) -> List[str]: + ptype = pin.get("type", "passive").lower() + shape = pin.get("shape", "line").lower() + at = pin.get("at", {"x": 0, "y": 0, "angle": 0}) + x = _fmt(at.get("x", 0)) + y = _fmt(at.get("y", 0)) + angle = _fmt(at.get("angle", 0)) + length = _fmt(pin.get("length", 2.54)) + pin_name = pin.get("name", "~") + pin_number = str(pin.get("number", "1")) + + return [ + f' (pin {ptype} {shape}', + f' (at {x} {y} {angle})', + f' (length {length})', + f' (name "{_esc(pin_name)}"', + f' (effects (font (size 1.27 1.27)))', + f' )', + f' (number "{_esc(pin_number)}"', + f' (effects (font (size 1.27 1.27)))', + f' )', + f' )', + ] diff --git a/python/kicad_interface.py b/python/kicad_interface.py index dcf1f54..dbd7675 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -224,6 +224,8 @@ try: from commands.jlcpcb import JLCPCBClient, test_jlcpcb_connection from commands.jlcpcb_parts import JLCPCBPartsManager from commands.datasheet_manager import DatasheetManager + from commands.footprint import FootprintCreator + from commands.symbol_creator import SymbolCreator logger.info("Successfully imported all command handlers") except ImportError as e: @@ -366,6 +368,7 @@ class KiCADInterface: "load_schematic": self._handle_load_schematic, "add_schematic_component": self._handle_add_schematic_component, "delete_schematic_component": self._handle_delete_schematic_component, + "edit_schematic_component": self._handle_edit_schematic_component, "add_schematic_wire": self._handle_add_schematic_wire, "add_schematic_connection": self._handle_add_schematic_connection, "add_schematic_net_label": self._handle_add_schematic_net_label, @@ -386,6 +389,16 @@ class KiCADInterface: "ipc_get_tracks": self._handle_ipc_get_tracks, "ipc_get_vias": self._handle_ipc_get_vias, "ipc_save_board": self._handle_ipc_save_board, + # Footprint commands + "create_footprint": self._handle_create_footprint, + "edit_footprint_pad": self._handle_edit_footprint_pad, + "list_footprint_libraries": self._handle_list_footprint_libraries, + "register_footprint_library": self._handle_register_footprint_library, + # Symbol creator commands + "create_symbol": self._handle_create_symbol, + "delete_symbol": self._handle_delete_symbol, + "list_symbols_in_library": self._handle_list_symbols_in_library, + "register_symbol_library": self._handle_register_symbol_library, } logger.info( @@ -585,6 +598,7 @@ class KiCADInterface: library = component.get("library", "Device") reference = component.get("reference", "X?") value = component.get("value", comp_type) + footprint = component.get("footprint", "") x = component.get("x", 0) y = component.get("y", 0) @@ -595,6 +609,7 @@ class KiCADInterface: comp_type, reference=reference, value=value, + footprint=footprint, x=x, y=y, ) @@ -646,9 +661,8 @@ class KiCADInterface: lib_sym_end = i break - # Find the placed symbol block matching the reference - block_start = None - block_end = None + # Find ALL placed symbol blocks matching the reference (handles duplicates) + blocks_to_delete = [] i = 0 while i < len(lines): # Skip lib_symbols @@ -666,35 +680,33 @@ class KiCADInterface: j += 1 b_end = j - 1 - # Check if this block contains the target reference block_text = "\n".join(lines[b_start:b_end + 1]) - # Match: (property "Reference" "R1" ... if re.search(r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"', block_text): - block_start = b_start - block_end = b_end - break + blocks_to_delete.append((b_start, b_end)) i = b_end + 1 continue i += 1 - if block_start is None: + if not blocks_to_delete: return { "success": False, "message": f"Component '{reference}' not found in schematic (note: this tool removes schematic symbols, use delete_component for PCB footprints)", } - # Remove the block (and any trailing blank line) - del lines[block_start:block_end + 1] - if block_start < len(lines) and lines[block_start].strip() == "": - del lines[block_start] + # Delete from back to front to preserve line indices + for b_start, b_end in sorted(blocks_to_delete, reverse=True): + del lines[b_start:b_end + 1] + if b_start < len(lines) and lines[b_start].strip() == "": + del lines[b_start] with open(sch_file, "w", encoding="utf-8") as f: f.write("\n".join(lines)) - logger.info(f"Deleted schematic component {reference} from {sch_file.name}") - return {"success": True, "reference": reference, "schematic": str(sch_file)} + deleted_count = len(blocks_to_delete) + logger.info(f"Deleted {deleted_count} instance(s) of {reference} from {sch_file.name}") + return {"success": True, "reference": reference, "deleted_count": deleted_count, "schematic": str(sch_file)} except Exception as e: logger.error(f"Error deleting schematic component: {e}") @@ -702,6 +714,98 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} + def _handle_edit_schematic_component(self, params): + """Update properties of a placed symbol in a schematic (footprint, value, reference). + Uses text-based in-place editing – preserves position, UUID and all other fields.""" + logger.info("Editing schematic component") + try: + from pathlib import Path + import re + + schematic_path = params.get("schematicPath") + reference = params.get("reference") + new_footprint = params.get("footprint") + new_value = params.get("value") + new_reference = params.get("newReference") + + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + if not reference: + return {"success": False, "message": "reference is required"} + if not any([new_footprint is not None, new_value is not None, new_reference is not None]): + return {"success": False, "message": "At least one of footprint, value, or newReference must be provided"} + + sch_file = Path(schematic_path) + if not sch_file.exists(): + return {"success": False, "message": f"Schematic not found: {schematic_path}"} + + with open(sch_file, "r", encoding="utf-8") as f: + lines = f.read().split("\n") + + # Find lib_symbols range to skip + lib_sym_start, lib_sym_end = None, None + depth = 0 + for i, line in enumerate(lines): + if "(lib_symbols" in line and lib_sym_start is None: + lib_sym_start = i + depth = sum(1 for c in line if c == "(") - sum(1 for c in line if c == ")") + elif lib_sym_start is not None and lib_sym_end is None: + depth += sum(1 for c in line if c == "(") - sum(1 for c in line if c == ")") + if depth == 0: + lib_sym_end = i + break + + # Find the placed symbol block + block_start = block_end = None + i = 0 + while i < len(lines): + if lib_sym_start is not None and lib_sym_end is not None: + if lib_sym_start <= i <= lib_sym_end: + i += 1 + continue + if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", lines[i]): + b_start = i + b_depth = sum(1 for c in lines[i] if c == "(") - sum(1 for c in lines[i] if c == ")") + j = i + 1 + while j < len(lines) and b_depth > 0: + b_depth += sum(1 for c in lines[j] if c == "(") - sum(1 for c in lines[j] if c == ")") + j += 1 + b_end = j - 1 + block_text = "\n".join(lines[b_start:b_end + 1]) + if re.search(r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"', block_text): + block_start, block_end = b_start, b_end + break + i = b_end + 1 + continue + i += 1 + + if block_start is None: + return {"success": False, "message": f"Component '{reference}' not found in schematic"} + + # Apply in-place property updates within the block + for k in range(block_start, block_end + 1): + line = lines[k] + if new_footprint is not None and re.match(r'\s*\(property\s+"Footprint"\s+"', line): + line = re.sub(r'(\(property\s+"Footprint"\s+)"[^"]*"', rf'\1"{new_footprint}"', line) + if new_value is not None and re.match(r'\s*\(property\s+"Value"\s+"', line): + line = re.sub(r'(\(property\s+"Value"\s+)"[^"]*"', rf'\1"{new_value}"', line) + if new_reference is not None and re.match(r'\s*\(property\s+"Reference"\s+"', line): + line = re.sub(r'(\(property\s+"Reference"\s+)"[^"]*"', rf'\1"{new_reference}"', line) + lines[k] = line + + with open(sch_file, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + + changes = {k: v for k, v in {"footprint": new_footprint, "value": new_value, "reference": new_reference}.items() if v is not None} + logger.info(f"Edited schematic component {reference}: {changes}") + return {"success": True, "reference": reference, "updated": changes} + + except Exception as e: + logger.error(f"Error editing schematic component: {e}") + import traceback + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + def _handle_add_schematic_wire(self, params): """Add a wire to a schematic using WireManager""" logger.info("Adding wire to schematic") @@ -762,6 +866,146 @@ class KiCADInterface: logger.error(f"Error listing schematic libraries: {str(e)}") return {"success": False, "message": str(e)} + # ------------------------------------------------------------------ # + # Footprint handlers # + # ------------------------------------------------------------------ # + + def _handle_create_footprint(self, params): + """Create a new .kicad_mod footprint file in a .pretty library.""" + logger.info(f"create_footprint: {params.get('name')} in {params.get('libraryPath')}") + try: + creator = FootprintCreator() + return creator.create_footprint( + library_path=params.get("libraryPath", ""), + name=params.get("name", ""), + description=params.get("description", ""), + tags=params.get("tags", ""), + pads=params.get("pads", []), + courtyard=params.get("courtyard"), + silkscreen=params.get("silkscreen"), + fab_layer=params.get("fabLayer"), + ref_position=params.get("refPosition"), + value_position=params.get("valuePosition"), + overwrite=params.get("overwrite", False), + ) + except Exception as e: + logger.error(f"create_footprint error: {e}") + return {"success": False, "error": str(e)} + + def _handle_edit_footprint_pad(self, params): + """Edit an existing pad in a .kicad_mod file.""" + logger.info(f"edit_footprint_pad: pad {params.get('padNumber')} in {params.get('footprintPath')}") + try: + creator = FootprintCreator() + return creator.edit_footprint_pad( + footprint_path=params.get("footprintPath", ""), + pad_number=str(params.get("padNumber", "1")), + size=params.get("size"), + at=params.get("at"), + drill=params.get("drill"), + shape=params.get("shape"), + ) + except Exception as e: + logger.error(f"edit_footprint_pad error: {e}") + return {"success": False, "error": str(e)} + + def _handle_list_footprint_libraries(self, params): + """List .pretty footprint libraries and their contents.""" + logger.info("list_footprint_libraries") + try: + creator = FootprintCreator() + return creator.list_footprint_libraries( + search_paths=params.get("searchPaths") + ) + except Exception as e: + logger.error(f"list_footprint_libraries error: {e}") + return {"success": False, "error": str(e)} + + def _handle_register_footprint_library(self, params): + """Register a .pretty library in KiCAD's fp-lib-table.""" + logger.info(f"register_footprint_library: {params.get('libraryPath')}") + try: + creator = FootprintCreator() + return creator.register_footprint_library( + library_path=params.get("libraryPath", ""), + library_name=params.get("libraryName"), + description=params.get("description", ""), + scope=params.get("scope", "project"), + project_path=params.get("projectPath"), + ) + except Exception as e: + logger.error(f"register_footprint_library error: {e}") + return {"success": False, "error": str(e)} + + # ------------------------------------------------------------------ # + # Symbol creator handlers # + # ------------------------------------------------------------------ # + + def _handle_create_symbol(self, params): + """Create a new symbol in a .kicad_sym library.""" + logger.info(f"create_symbol: {params.get('name')} in {params.get('libraryPath')}") + try: + creator = SymbolCreator() + return creator.create_symbol( + library_path=params.get("libraryPath", ""), + name=params.get("name", ""), + reference_prefix=params.get("referencePrefix", "U"), + description=params.get("description", ""), + keywords=params.get("keywords", ""), + datasheet=params.get("datasheet", "~"), + footprint=params.get("footprint", ""), + in_bom=params.get("inBom", True), + on_board=params.get("onBoard", True), + pins=params.get("pins", []), + rectangles=params.get("rectangles", []), + polylines=params.get("polylines", []), + overwrite=params.get("overwrite", False), + ) + except Exception as e: + logger.error(f"create_symbol error: {e}") + return {"success": False, "error": str(e)} + + def _handle_delete_symbol(self, params): + """Delete a symbol from a .kicad_sym library.""" + logger.info(f"delete_symbol: {params.get('name')} from {params.get('libraryPath')}") + try: + creator = SymbolCreator() + return creator.delete_symbol( + library_path=params.get("libraryPath", ""), + name=params.get("name", ""), + ) + except Exception as e: + logger.error(f"delete_symbol error: {e}") + return {"success": False, "error": str(e)} + + def _handle_list_symbols_in_library(self, params): + """List all symbols in a .kicad_sym file.""" + logger.info(f"list_symbols_in_library: {params.get('libraryPath')}") + try: + creator = SymbolCreator() + return creator.list_symbols( + library_path=params.get("libraryPath", ""), + ) + except Exception as e: + logger.error(f"list_symbols_in_library error: {e}") + return {"success": False, "error": str(e)} + + def _handle_register_symbol_library(self, params): + """Register a .kicad_sym library in KiCAD's sym-lib-table.""" + logger.info(f"register_symbol_library: {params.get('libraryPath')}") + try: + creator = SymbolCreator() + return creator.register_symbol_library( + library_path=params.get("libraryPath", ""), + library_name=params.get("libraryName"), + description=params.get("description", ""), + scope=params.get("scope", "project"), + project_path=params.get("projectPath"), + ) + except Exception as e: + logger.error(f"register_symbol_library error: {e}") + return {"success": False, "error": str(e)} + def _handle_export_schematic_pdf(self, params): """Export schematic to PDF""" logger.info("Exporting schematic to PDF") diff --git a/python/templates/empty.kicad_sch b/python/templates/empty.kicad_sch index 55b5d0c..2ab0744 100644 --- a/python/templates/empty.kicad_sch +++ b/python/templates/empty.kicad_sch @@ -1,4 +1,4 @@ -(kicad_sch (version 20230121) (generator "KiCAD-MCP-Server") +(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server") (uuid 00000000-0000-0000-0000-000000000000) diff --git a/python/templates/minimal.kicad_sch b/python/templates/minimal.kicad_sch new file mode 100644 index 0000000..918129a --- /dev/null +++ b/python/templates/minimal.kicad_sch @@ -0,0 +1,13 @@ +(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server") + + (uuid 00000000-0000-0000-0000-000000000000) + + (paper "A4") + + (lib_symbols + ) + + (sheet_instances + (path "/" (page "1")) + ) +) diff --git a/python/templates/template_with_symbols.kicad_sch b/python/templates/template_with_symbols.kicad_sch index 8183ad2..940ba7c 100644 --- a/python/templates/template_with_symbols.kicad_sch +++ b/python/templates/template_with_symbols.kicad_sch @@ -1,4 +1,4 @@ -(kicad_sch (version 20230121) (generator "KiCAD-MCP-Server") +(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server") (uuid a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d) @@ -131,7 +131,7 @@ ) ) - (symbol (lib_id "Device:R") (at -100 -100 0) (unit 1) + (symbol (lib_id "Device:R") (at -10000 -10000 0) (unit 1) (in_bom no) (on_board no) (dnp yes) (uuid 00000000-0000-0000-0000-000000000001) (property "Reference" "_TEMPLATE_R" (at -100 -102.54 0) @@ -143,14 +143,14 @@ (property "Footprint" "" (at -100 -100 90) (effects (font (size 1.27 1.27)) hide) ) - (property "Datasheet" "~" (at -100 -100 0) + (property "Datasheet" "~" (at -10000 -10000 0) (effects (font (size 1.27 1.27)) hide) ) (pin "1" (uuid 00000000-0000-0000-0000-000000000010)) (pin "2" (uuid 00000000-0000-0000-0000-000000000011)) ) - (symbol (lib_id "Device:C") (at -100 -110 0) (unit 1) + (symbol (lib_id "Device:C") (at -10000 -10000 0) (unit 1) (in_bom no) (on_board no) (dnp yes) (uuid 00000000-0000-0000-0000-000000000002) (property "Reference" "_TEMPLATE_C" (at -100 -107.46 0) @@ -159,17 +159,17 @@ (property "Value" "C_TEMPLATE" (at -100 -112.54 0) (effects (font (size 1.27 1.27))) ) - (property "Footprint" "" (at -100 -110 0) + (property "Footprint" "" (at -10000 -10000 0) (effects (font (size 1.27 1.27)) hide) ) - (property "Datasheet" "~" (at -100 -110 0) + (property "Datasheet" "~" (at -10000 -10000 0) (effects (font (size 1.27 1.27)) hide) ) (pin "1" (uuid 00000000-0000-0000-0000-000000000020)) (pin "2" (uuid 00000000-0000-0000-0000-000000000021)) ) - (symbol (lib_id "Device:LED") (at -100 -120 0) (unit 1) + (symbol (lib_id "Device:LED") (at -10000 -10000 0) (unit 1) (in_bom no) (on_board no) (dnp yes) (uuid 00000000-0000-0000-0000-000000000003) (property "Reference" "_TEMPLATE_D" (at -100 -117.46 0) @@ -178,10 +178,10 @@ (property "Value" "LED_TEMPLATE" (at -100 -122.54 0) (effects (font (size 1.27 1.27))) ) - (property "Footprint" "" (at -100 -120 0) + (property "Footprint" "" (at -10000 -10000 0) (effects (font (size 1.27 1.27)) hide) ) - (property "Datasheet" "~" (at -100 -120 0) + (property "Datasheet" "~" (at -10000 -10000 0) (effects (font (size 1.27 1.27)) hide) ) (pin "1" (uuid 00000000-0000-0000-0000-000000000030)) diff --git a/python/templates/template_with_symbols_expanded.kicad_sch b/python/templates/template_with_symbols_expanded.kicad_sch index 826d7d4..e4cfb8d 100644 --- a/python/templates/template_with_symbols_expanded.kicad_sch +++ b/python/templates/template_with_symbols_expanded.kicad_sch @@ -1,11 +1,11 @@ -(kicad_sch (version 20230121) (generator "KiCAD-MCP-Server") +(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server") (uuid 00000000-0000-0000-0000-000000000000) (paper "A4") (lib_symbols - ;; PASSIVES + (symbol "Device:R" (pin_numbers hide) (pin_names (offset 0)) (in_bom yes) (on_board yes) (property "Reference" "R" (at 2.032 0 90) (effects (font (size 1.27 1.27))) @@ -182,7 +182,7 @@ ) ) ) - ;; SEMICONDUCTORS + (symbol "Device:D" (pin_numbers hide) (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes) (property "Reference" "D" (at 0 2.54 0) (effects (font (size 1.27 1.27))) @@ -497,7 +497,7 @@ ) ) ) - ;; INTEGRATED CIRCUITS + (symbol "Amplifier_Operational:LM358" (pin_names (offset 0.127)) (in_bom yes) (on_board yes) (property "Reference" "U" (at 0 5.08 0) (effects (font (size 1.27 1.27)) (justify left)) @@ -546,7 +546,7 @@ ) ) ) - ;; CONNECTORS + (symbol "Connector_Generic:Conn_01x02" (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes) (property "Reference" "J" (at 0 2.54 0) (effects (font (size 1.27 1.27))) @@ -635,7 +635,7 @@ ) ) ) - ;; POWER/REGULATORS + (symbol "Regulator_Linear:AMS1117-3.3" (pin_names (offset 0.254)) (in_bom yes) (on_board yes) (property "Reference" "U" (at -3.81 3.175 0) (effects (font (size 1.27 1.27))) @@ -670,7 +670,7 @@ ) ) ) - ;; MISC + (symbol "Switch:SW_Push" (pin_numbers hide) (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes) (property "Reference" "SW" (at 1.27 2.54 0) (effects (font (size 1.27 1.27)) (justify left)) @@ -721,261 +721,19 @@ ) ) - ;; TEMPLATE INSTANCES (placed offscreen at -100, -110, etc.) - (symbol (lib_id "Device:R") (at -100 -100 0) (unit 1) - (in_bom no) (on_board no) (dnp yes) - (uuid 00000000-0000-0000-0000-000000000001) - (property "Reference" "_TEMPLATE_R" (at -100 -102.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "R" (at -100 -100 90) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at -100 -100 90) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at -100 -100 0) - (effects (font (size 1.27 1.27)) hide) - ) - (pin "1" (uuid 10000000-0000-0000-0000-000000000001)) - (pin "2" (uuid 10000000-0000-0000-0000-000000000002)) - ) + - (symbol (lib_id "Device:C") (at -100 -110 0) (unit 1) - (in_bom no) (on_board no) (dnp yes) - (uuid 00000000-0000-0000-0000-000000000002) - (property "Reference" "_TEMPLATE_C" (at -100 -107.46 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "C" (at -100 -112.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at -100 -110 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at -100 -110 0) - (effects (font (size 1.27 1.27)) hide) - ) - (pin "1" (uuid 20000000-0000-0000-0000-000000000001)) - (pin "2" (uuid 20000000-0000-0000-0000-000000000002)) - ) - (symbol (lib_id "Device:L") (at -100 -120 0) (unit 1) - (in_bom no) (on_board no) (dnp yes) - (uuid 00000000-0000-0000-0000-000000000003) - (property "Reference" "_TEMPLATE_L" (at -100 -117.46 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "L" (at -100 -122.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at -100 -120 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at -100 -120 0) - (effects (font (size 1.27 1.27)) hide) - ) - (pin "1" (uuid 30000000-0000-0000-0000-000000000001)) - (pin "2" (uuid 30000000-0000-0000-0000-000000000002)) - ) - (symbol (lib_id "Device:Crystal") (at -100 -130 0) (unit 1) - (in_bom no) (on_board no) (dnp yes) - (uuid 00000000-0000-0000-0000-000000000004) - (property "Reference" "_TEMPLATE_Y" (at -100 -127.46 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "Crystal" (at -100 -132.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at -100 -130 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at -100 -130 0) - (effects (font (size 1.27 1.27)) hide) - ) - (pin "1" (uuid 40000000-0000-0000-0000-000000000001)) - (pin "2" (uuid 40000000-0000-0000-0000-000000000002)) - ) - (symbol (lib_id "Device:D") (at -100 -140 0) (unit 1) - (in_bom no) (on_board no) (dnp yes) - (uuid 00000000-0000-0000-0000-000000000005) - (property "Reference" "_TEMPLATE_D" (at -100 -137.46 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "D" (at -100 -142.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at -100 -140 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at -100 -140 0) - (effects (font (size 1.27 1.27)) hide) - ) - (pin "1" (uuid 50000000-0000-0000-0000-000000000001)) - (pin "2" (uuid 50000000-0000-0000-0000-000000000002)) - ) - (symbol (lib_id "Device:LED") (at -100 -150 0) (unit 1) - (in_bom no) (on_board no) (dnp yes) - (uuid 00000000-0000-0000-0000-000000000006) - (property "Reference" "_TEMPLATE_LED" (at -100 -147.46 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "LED" (at -100 -152.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at -100 -150 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at -100 -150 0) - (effects (font (size 1.27 1.27)) hide) - ) - (pin "1" (uuid 60000000-0000-0000-0000-000000000001)) - (pin "2" (uuid 60000000-0000-0000-0000-000000000002)) - ) - (symbol (lib_id "Device:Q_NPN_BCE") (at -100 -160 0) (unit 1) - (in_bom no) (on_board no) (dnp yes) - (uuid 00000000-0000-0000-0000-000000000007) - (property "Reference" "_TEMPLATE_Q_NPN" (at -100 -157.46 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "Q_NPN" (at -100 -162.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at -100 -160 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at -100 -160 0) - (effects (font (size 1.27 1.27)) hide) - ) - (pin "1" (uuid 70000000-0000-0000-0000-000000000001)) - (pin "2" (uuid 70000000-0000-0000-0000-000000000002)) - (pin "3" (uuid 70000000-0000-0000-0000-000000000003)) - ) - (symbol (lib_id "Device:Q_NMOS_GSD") (at -100 -170 0) (unit 1) - (in_bom no) (on_board no) (dnp yes) - (uuid 00000000-0000-0000-0000-000000000008) - (property "Reference" "_TEMPLATE_Q_NMOS" (at -100 -167.46 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "Q_NMOS" (at -100 -172.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at -100 -170 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at -100 -170 0) - (effects (font (size 1.27 1.27)) hide) - ) - (pin "1" (uuid 80000000-0000-0000-0000-000000000001)) - (pin "2" (uuid 80000000-0000-0000-0000-000000000002)) - (pin "3" (uuid 80000000-0000-0000-0000-000000000003)) - ) - (symbol (lib_id "Amplifier_Operational:LM358") (at -100 -180 0) (unit 1) - (in_bom no) (on_board no) (dnp yes) - (uuid 00000000-0000-0000-0000-000000000009) - (property "Reference" "_TEMPLATE_U_OPAMP" (at -100 -177.46 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "OpAmp" (at -100 -182.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at -100 -180 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "" (at -100 -180 0) - (effects (font (size 1.27 1.27)) hide) - ) - (pin "1" (uuid 90000000-0000-0000-0000-000000000001)) - (pin "2" (uuid 90000000-0000-0000-0000-000000000002)) - (pin "3" (uuid 90000000-0000-0000-0000-000000000003)) - (pin "4" (uuid 90000000-0000-0000-0000-000000000004)) - (pin "8" (uuid 90000000-0000-0000-0000-000000000005)) - ) - (symbol (lib_id "Connector_Generic:Conn_01x02") (at -100 -190 0) (unit 1) - (in_bom no) (on_board no) (dnp yes) - (uuid 00000000-0000-0000-0000-00000000000A) - (property "Reference" "_TEMPLATE_J2" (at -100 -187.46 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "Conn_2" (at -100 -192.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at -100 -190 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at -100 -190 0) - (effects (font (size 1.27 1.27)) hide) - ) - (pin "1" (uuid A0000000-0000-0000-0000-000000000001)) - (pin "2" (uuid A0000000-0000-0000-0000-000000000002)) - ) - (symbol (lib_id "Connector_Generic:Conn_01x04") (at -100 -200 0) (unit 1) - (in_bom no) (on_board no) (dnp yes) - (uuid 00000000-0000-0000-0000-00000000000B) - (property "Reference" "_TEMPLATE_J4" (at -100 -197.46 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "Conn_4" (at -100 -202.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at -100 -200 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at -100 -200 0) - (effects (font (size 1.27 1.27)) hide) - ) - (pin "1" (uuid B0000000-0000-0000-0000-000000000001)) - (pin "2" (uuid B0000000-0000-0000-0000-000000000002)) - (pin "3" (uuid B0000000-0000-0000-0000-000000000003)) - (pin "4" (uuid B0000000-0000-0000-0000-000000000004)) - ) - (symbol (lib_id "Regulator_Linear:AMS1117-3.3") (at -100 -210 0) (unit 1) - (in_bom no) (on_board no) (dnp yes) - (uuid 00000000-0000-0000-0000-00000000000C) - (property "Reference" "_TEMPLATE_U_REG" (at -100 -207.46 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "Regulator" (at -100 -212.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at -100 -210 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "" (at -100 -210 0) - (effects (font (size 1.27 1.27)) hide) - ) - (pin "1" (uuid C0000000-0000-0000-0000-000000000001)) - (pin "2" (uuid C0000000-0000-0000-0000-000000000002)) - (pin "3" (uuid C0000000-0000-0000-0000-000000000003)) - ) - (symbol (lib_id "Switch:SW_Push") (at -100 -220 0) (unit 1) - (in_bom no) (on_board no) (dnp yes) - (uuid 00000000-0000-0000-0000-00000000000D) - (property "Reference" "_TEMPLATE_SW" (at -100 -217.46 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "Switch" (at -100 -222.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at -100 -220 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at -100 -220 0) - (effects (font (size 1.27 1.27)) hide) - ) - (pin "1" (uuid D0000000-0000-0000-0000-000000000001)) - (pin "2" (uuid D0000000-0000-0000-0000-000000000002)) - ) (sheet_instances (path "/" (page "1")) diff --git a/src/prompts/footprint.ts b/src/prompts/footprint.ts new file mode 100644 index 0000000..81556af --- /dev/null +++ b/src/prompts/footprint.ts @@ -0,0 +1,137 @@ +/** + * Footprint prompts for KiCAD MCP server + * + * Guides Claude in creating and editing KiCAD footprints (.kicad_mod) + * using the create_footprint, edit_footprint_pad, and list_footprint_libraries tools. + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { logger } from "../logger.js"; + +export function registerFootprintPrompts(server: McpServer): void { + logger.info("Registering footprint prompts"); + + // ------------------------------------------------------ + // Create Footprint Prompt + // ------------------------------------------------------ + server.prompt( + "create_footprint_guide", + { + component: z + .string() + .describe( + "Component description, e.g. 'SOT-23 NPN transistor' or '2-pin JST XH 2.5mm connector'", + ), + libraryPath: z + .string() + .optional() + .describe("Target .pretty library path (optional)"), + }, + () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `You are a KiCAD footprint expert. Create a correct KiCAD 9 footprint using the create_footprint tool. + +## Component to footprint +{{component}} + +## Library path +{{libraryPath}} + +## Rules for correct footprints + +### Coordinate system +- Origin (0,0) is the footprint anchor, typically the centre of the pad pattern. +- X increases to the right, Y increases downward (same as KiCAD screen). +- All values in millimetres. + +### SMD pads +- type: "smd" +- Default layers: ["F.Cu", "F.Paste", "F.Mask"] +- No drill needed. +- Common shapes: "rect" for square/rectangular, "roundrect" for ICs. + +### THT pads +- type: "thru_hole" +- Default layers: ["*.Cu", "*.Mask"] +- drill required (round = scalar, oval = {w, h}). +- Pad 1 is typically square (rect), remaining pads are circle. + +### Courtyard (F.CrtYd) +- Add 0.25 mm clearance around the outermost extent of pads. +- Line width: 0.05 mm. + +### Silkscreen (F.SilkS) +- Shows the component body outline, typically slightly inside the courtyard. +- Line width: 0.12 mm. +- Must not overlap pads. + +### Fab layer (F.Fab) +- Shows the realistic component outline with pin-1 marker. +- Line width: 0.10 mm. + +### Reference text +- Place "REF**" above the courtyard (negative Y = above). +- Value text below the courtyard (positive Y = below). + +## Workflow +1. Calculate pad positions from datasheet pitch and land pattern. +2. Call create_footprint with pads[], courtyard, silkscreen, fabLayer. +3. Verify with edit_footprint_pad if any correction is needed. + +## Common packages quick reference +| Package | Pitch | Pad size (SMD) | Notes | +|-----------|--------|------------------|------------------------------| +| 0402 | 1.0 mm | 0.6 × 0.7 mm | Very small, min 0.5 mm drill | +| 0603 | 1.6 mm | 1.0 × 1.0 mm | Standard small passive | +| 0805 | 2.0 mm | 1.4 × 1.2 mm | Easy to hand-solder | +| SOT-23 | 0.95 mm| 1.0 × 1.3 mm | 3-pin, 2 on one side | +| SOT-23-5 | 0.95 mm| 0.6 × 1.0 mm | 5-pin | +| SOIC-8 | 1.27 mm| 1.6 × 0.6 mm | 4 pins each side | +| DIP-8 | 2.54 mm| dia 1.6, drill 0.8| THT, 100 mil grid | + +Now create the footprint for: {{component}}`, + }, + }, + ], + }), + ); + + // ------------------------------------------------------ + // Footprint IPC Checklist Prompt + // ------------------------------------------------------ + server.prompt( + "footprint_ipc_checklist", + { + footprintPath: z + .string() + .describe("Path to the .kicad_mod file to review"), + }, + () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `Review the footprint at {{footprintPath}} against IPC-7351 land pattern guidelines. + +Check: +1. **Pad size** – is the copper area sufficient for soldering (not undersized)? +2. **Courtyard** – at least 0.25 mm clearance around all pads? +3. **Silkscreen** – does it overlap pads? (it should NOT) +4. **Pad 1 marker** – is pin 1 identifiable (square pad or triangle on silkscreen)? +5. **Drill size** – for THT: drill ≥ lead diameter + 0.3 mm? +6. **Layer assignments** – SMD pads: F.Cu/F.Paste/F.Mask; THT: *.Cu/*.Mask? +7. **Anchor** – is the origin centred on the pad pattern? + +Use edit_footprint_pad to fix any issues found.`, + }, + }, + ], + }), + ); +} diff --git a/src/prompts/index.ts b/src/prompts/index.ts index 78da99e..c6e8b3e 100644 --- a/src/prompts/index.ts +++ b/src/prompts/index.ts @@ -1,9 +1,10 @@ -/** - * Prompts index for KiCAD MCP server - * - * Exports all prompt registration functions - */ - -export { registerComponentPrompts } from './component.js'; -export { registerRoutingPrompts } from './routing.js'; -export { registerDesignPrompts } from './design.js'; +/** + * Prompts index for KiCAD MCP server + * + * Exports all prompt registration functions + */ + +export { registerComponentPrompts } from "./component.js"; +export { registerRoutingPrompts } from "./routing.js"; +export { registerDesignPrompts } from "./design.js"; +export { registerFootprintPrompts } from "./footprint.js"; diff --git a/src/server.ts b/src/server.ts index e2e847d..4f93cbd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -22,6 +22,8 @@ import { registerLibraryTools } from "./tools/library.js"; import { registerSymbolLibraryTools } from "./tools/library-symbol.js"; import { registerJLCPCBApiTools } from "./tools/jlcpcb-api.js"; import { registerDatasheetTools } from "./tools/datasheet.js"; +import { registerFootprintTools } from "./tools/footprint.js"; +import { registerSymbolCreatorTools } from "./tools/symbol-creator.js"; import { registerUITools } from "./tools/ui.js"; import { registerRouterTools } from "./tools/router.js"; @@ -35,6 +37,7 @@ import { registerLibraryResources } from "./resources/library.js"; import { registerComponentPrompts } from "./prompts/component.js"; import { registerRoutingPrompts } from "./prompts/routing.js"; import { registerDesignPrompts } from "./prompts/design.js"; +import { registerFootprintPrompts } from "./prompts/footprint.js"; /** * Find the Python executable to use @@ -243,6 +246,8 @@ export class KiCADMcpServer { registerSymbolLibraryTools(this.server, this.callKicadScript.bind(this)); registerJLCPCBApiTools(this.server, this.callKicadScript.bind(this)); registerDatasheetTools(this.server, this.callKicadScript.bind(this)); + registerFootprintTools(this.server, this.callKicadScript.bind(this)); + registerSymbolCreatorTools(this.server, this.callKicadScript.bind(this)); registerUITools(this.server, this.callKicadScript.bind(this)); // Register all resources @@ -255,6 +260,7 @@ export class KiCADMcpServer { registerComponentPrompts(this.server); registerRoutingPrompts(this.server); registerDesignPrompts(this.server); + registerFootprintPrompts(this.server); logger.info("All KiCAD tools, resources, and prompts registered"); logger.info( diff --git a/src/tools/footprint.ts b/src/tools/footprint.ts new file mode 100644 index 0000000..c7d4afd --- /dev/null +++ b/src/tools/footprint.ts @@ -0,0 +1,237 @@ +/** + * Footprint tools for KiCAD MCP server + * + * create_footprint – generate a complete .kicad_mod file in a .pretty library + * edit_footprint_pad – update size / position / drill / shape of one pad + * list_footprint_libraries – list available .pretty libraries + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +// ---- shared sub-schemas ------------------------------------------------- // + +const PadPosition = z.object({ + x: z.number().describe("X position in mm"), + y: z.number().describe("Y position in mm"), + angle: z.number().optional().describe("Rotation angle in degrees (default 0)"), +}); + +const PadSize = z.object({ + w: z.number().describe("Width in mm"), + h: z.number().describe("Height in mm"), +}); + +const PadSchema = z.object({ + number: z.string().describe("Pad number / name, e.g. '1', '2', 'A1'"), + type: z + .enum(["smd", "thru_hole", "np_thru_hole"]) + .describe("Pad type: smd | thru_hole | np_thru_hole"), + shape: z + .enum(["rect", "circle", "oval", "roundrect"]) + .optional() + .describe("Pad shape (default: rect for SMD, circle for THT)"), + at: PadPosition.describe("Pad centre position"), + size: PadSize.describe("Pad size in mm"), + drill: z + .union([ + z.number().describe("Round drill diameter in mm"), + z.object({ w: z.number(), h: z.number() }).describe("Oval drill w×h in mm"), + ]) + .optional() + .describe("Drill size (required for thru_hole pads)"), + layers: z + .array(z.string()) + .optional() + .describe("Override default layer list, e.g. ['F.Cu','F.Paste','F.Mask']"), + roundrect_ratio: z + .number() + .min(0) + .max(0.5) + .optional() + .describe("Corner radius ratio for roundrect shape (0.0–0.5, default 0.25)"), +}); + +const RectSchema = z.object({ + x1: z.number().describe("Left X in mm"), + y1: z.number().describe("Top Y in mm"), + x2: z.number().describe("Right X in mm"), + y2: z.number().describe("Bottom Y in mm"), + width: z.number().optional().describe("Line width in mm"), +}); + +// ---- tool registration --------------------------------------------------- // + +export function registerFootprintTools( + server: McpServer, + callKicadScript: Function, +) { + // ── create_footprint ──────────────────────────────────────────────────── // + server.tool( + "create_footprint", + "Create a new KiCAD footprint (.kicad_mod) inside a .pretty library directory. " + + "Supports SMD and THT pads, courtyard, silkscreen, and fab-layer rectangles.", + { + libraryPath: z + .string() + .describe( + "Path to the .pretty library directory (created if missing). " + + "E.g. C:/MyProject/MyLib.pretty", + ), + name: z.string().describe("Footprint name, e.g. 'R_0603_Custom'"), + description: z.string().optional().describe("Human-readable description"), + tags: z + .string() + .optional() + .describe("Space-separated tag string, e.g. 'resistor SMD 0603'"), + pads: z + .array(PadSchema) + .optional() + .describe("List of pads to add (can be empty for outlines-only footprints)"), + courtyard: RectSchema.optional().describe( + "Courtyard rectangle on F.CrtYd (recommended: 0.25 mm clearance around pads)", + ), + silkscreen: RectSchema.optional().describe( + "Silkscreen rectangle on F.SilkS", + ), + fabLayer: RectSchema.optional().describe( + "Fab-layer rectangle on F.Fab (shows component body)", + ), + refPosition: z + .object({ x: z.number(), y: z.number() }) + .optional() + .describe("Position of the REF** text (default: 0, -1.27)"), + valuePosition: z + .object({ x: z.number(), y: z.number() }) + .optional() + .describe("Position of the Value text (default: 0, 1.27)"), + overwrite: z + .boolean() + .optional() + .describe("Replace existing footprint file (default: false)"), + }, + async (args: { + libraryPath: string; + name: string; + description?: string; + tags?: string; + pads?: z.infer[]; + courtyard?: z.infer; + silkscreen?: z.infer; + fabLayer?: z.infer; + refPosition?: { x: number; y: number }; + valuePosition?: { x: number; y: number }; + overwrite?: boolean; + }) => { + const result = await callKicadScript("create_footprint", args); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + }, + ); + + // ── edit_footprint_pad ────────────────────────────────────────────────── // + server.tool( + "edit_footprint_pad", + "Edit an existing pad inside a .kicad_mod footprint file. " + + "Updates size, position, drill, or shape without recreating the whole footprint.", + { + footprintPath: z + .string() + .describe("Full path to the .kicad_mod file, e.g. C:/MyLib.pretty/R_Custom.kicad_mod"), + padNumber: z + .union([z.string(), z.number()]) + .describe("Pad number to edit, e.g. '1' or 2"), + size: PadSize.optional().describe("New pad size in mm"), + at: PadPosition.optional().describe("New pad position in mm"), + drill: z + .union([ + z.number().describe("Round drill diameter in mm"), + z.object({ w: z.number(), h: z.number() }).describe("Oval drill"), + ]) + .optional() + .describe("New drill size (for THT pads)"), + shape: z + .enum(["rect", "circle", "oval", "roundrect"]) + .optional() + .describe("New pad shape"), + }, + async (args: { + footprintPath: string; + padNumber: string | number; + size?: { w: number; h: number }; + at?: { x: number; y: number; angle?: number }; + drill?: number | { w: number; h: number }; + shape?: string; + }) => { + const result = await callKicadScript("edit_footprint_pad", args); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + }, + ); + + // ── register_footprint_library ───────────────────────────────────────── // + server.tool( + "register_footprint_library", + "Register a .pretty footprint library in KiCAD's fp-lib-table so KiCAD can find the footprints. " + + "Run this after create_footprint when KiCAD shows 'library not found in footprint library table'.", + { + libraryPath: z + .string() + .describe("Full path to the .pretty directory to register"), + libraryName: z + .string() + .optional() + .describe("Nickname for the library in KiCAD (default: directory name without .pretty)"), + description: z.string().optional().describe("Optional description"), + scope: z + .enum(["project", "global"]) + .optional() + .describe( + "project = writes fp-lib-table next to the .kicad_pro file (default); " + + "global = writes to the user's global KiCAD config", + ), + projectPath: z + .string() + .optional() + .describe( + "Path to the .kicad_pro file or its directory (required for scope=project " + + "when the library is not in the project folder)", + ), + }, + async (args: { + libraryPath: string; + libraryName?: string; + description?: string; + scope?: "project" | "global"; + projectPath?: string; + }) => { + const result = await callKicadScript("register_footprint_library", args); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + }, + ); + + // ── list_footprint_libraries ─────────────────────────────────────────── // + server.tool( + "list_footprint_libraries", + "List available .pretty footprint libraries and their contents (first 20 footprints per library). " + + "Searches KiCAD standard install paths by default.", + { + searchPaths: z + .array(z.string()) + .optional() + .describe( + "Override default search paths. Each entry should be a directory that contains .pretty subdirs.", + ), + }, + async (args: { searchPaths?: string[] }) => { + const result = await callKicadScript("list_footprint_libraries", args); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + }, + ); +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 225f964..62b6d91 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -14,3 +14,5 @@ export { registerSchematicTools } from "./schematic.js"; export { registerLibraryTools } from "./library.js"; export { registerUITools } from "./ui.js"; export { registerDatasheetTools } from "./datasheet.js"; +export { registerFootprintTools } from "./footprint.js"; +export { registerSymbolCreatorTools } from "./symbol-creator.js"; diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index 23f9ff1..4db17fc 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -43,6 +43,7 @@ export function registerSchematicTools( ), reference: z.string().describe("Component reference (e.g., R1, U1)"), value: z.string().optional().describe("Component value"), + footprint: z.string().optional().describe("KiCAD footprint (e.g. Resistor_SMD:R_0603_1608Metric)"), position: z .object({ x: z.number(), @@ -56,6 +57,7 @@ export function registerSchematicTools( symbol: string; reference: string; value?: string; + footprint?: string; position?: { x: number; y: number }; }) => { // Transform to what Python backend expects @@ -70,6 +72,7 @@ export function registerSchematicTools( type: symbolName, reference: args.reference, value: args.value, + footprint: args.footprint ?? "", // Python expects flat x, y not nested position x: args.position?.x ?? 0, y: args.position?.y ?? 0, @@ -141,6 +144,55 @@ To remove a footprint from a PCB, use delete_component instead.`, }, ); + // Edit component properties in schematic (footprint, value, reference) + server.tool( + "edit_schematic_component", + `Update properties of a placed symbol in a KiCAD schematic (.kicad_sch) in-place. + +Use this tool to assign or update a footprint, change the value, or rename the reference +of an already-placed component. This is more efficient than delete + re-add because it +preserves the component's position and UUID. + +Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_component.`, + { + schematicPath: z.string().describe("Path to the .kicad_sch file"), + reference: z.string().describe("Current reference designator of the component (e.g. R1, U3)"), + footprint: z.string().optional().describe("New KiCAD footprint string (e.g. Resistor_SMD:R_0603_1608Metric)"), + value: z.string().optional().describe("New value string (e.g. 10k, 100nF)"), + newReference: z.string().optional().describe("Rename the reference designator (e.g. R1 → R10)"), + }, + async (args: { + schematicPath: string; + reference: string; + footprint?: string; + value?: string; + newReference?: string; + }) => { + const result = await callKicadScript("edit_schematic_component", args); + if (result.success) { + const changes = Object.entries(result.updated ?? {}) + .map(([k, v]) => `${k}=${v}`) + .join(", "); + return { + content: [ + { + type: "text" as const, + text: `Successfully updated ${args.reference}: ${changes}`, + }, + ], + }; + } + return { + content: [ + { + type: "text" as const, + text: `Failed to edit component: ${result.message || "Unknown error"}`, + }, + ], + }; + }, + ); + // Connect components with wire server.tool( "add_wire", diff --git a/src/tools/symbol-creator.ts b/src/tools/symbol-creator.ts new file mode 100644 index 0000000..046021e --- /dev/null +++ b/src/tools/symbol-creator.ts @@ -0,0 +1,194 @@ +/** + * Symbol creator tools for KiCAD MCP server + * + * create_symbol – add a new symbol to a .kicad_sym library + * delete_symbol – remove a symbol from a library + * list_symbols_in_library – list all symbols in a .kicad_sym file + * register_symbol_library – add library to sym-lib-table + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +const PinSchema = z.object({ + name: z.string().describe("Pin name, e.g. 'VCC', 'GND', 'IN+', '~' for unnamed"), + number: z.union([z.string(), z.number()]).describe("Pin number, e.g. '1', '2', 'A1'"), + type: z + .enum([ + "input", "output", "bidirectional", "tri_state", "passive", + "free", "unspecified", "power_in", "power_out", + "open_collector", "open_emitter", "no_connect", + ]) + .describe("Electrical pin type"), + at: z.object({ + x: z.number().describe("X position in mm"), + y: z.number().describe("Y position in mm"), + angle: z.number().describe( + "Direction the pin wire extends FROM the symbol body: 0=right, 90=up, 180=left, 270=down" + ), + }).describe("Pin endpoint position (where the wire connects)"), + length: z.number().optional().describe("Pin length in mm (default 2.54)"), + shape: z + .enum(["line", "inverted", "clock", "inverted_clock", "input_low", + "clock_low", "output_low", "falling_edge_clock", "non_logic"]) + .optional() + .describe("Pin graphic shape (default: line)"), +}); + +const RectSchema = z.object({ + x1: z.number(), y1: z.number(), + x2: z.number(), y2: z.number(), + width: z.number().optional().describe("Stroke width in mm (default 0.254)"), + fill: z.enum(["none", "outline", "background"]).optional() + .describe("Fill type (default: background)"), +}); + +const PolylineSchema = z.object({ + points: z.array(z.object({ x: z.number(), y: z.number() })) + .describe("List of XY points in mm"), + width: z.number().optional().describe("Stroke width in mm (default 0.254)"), + fill: z.enum(["none", "outline", "background"]).optional(), +}); + +export function registerSymbolCreatorTools( + server: McpServer, + callKicadScript: Function, +) { + // ── create_symbol ────────────────────────────────────────────────────── // + server.tool( + "create_symbol", + "Create a new schematic symbol in a .kicad_sym library file (created if missing). " + + "After creation, use register_symbol_library so KiCAD finds it. " + + "Pin positions are where the wire connects; the symbol body is drawn between them.\n\n" + + "Coordinate tips:\n" + + "- Body rectangle typically spans ±2.54 to ±5.08 mm\n" + + "- Pins on left side: at.x = body_left - length, angle=0 (wire goes right)\n" + + "- Pins on right side: at.x = body_right + length, angle=180 (wire goes left)\n" + + "- Pins on top: at.y = body_top + length, angle=270 (wire goes down)\n" + + "- Pins on bottom: at.y = body_bottom - length, angle=90 (wire goes up)\n" + + "- Standard pin length: 2.54 mm, standard grid: 2.54 mm", + { + libraryPath: z + .string() + .describe("Path to the .kicad_sym file (created if missing)"), + name: z.string().describe("Symbol name, e.g. 'TMC2209', 'MyOpAmp'"), + referencePrefix: z + .string() + .optional() + .describe("Schematic reference prefix: 'U' (IC), 'R' (resistor), 'J' (connector), etc. Default: 'U'"), + description: z.string().optional().describe("Human-readable description"), + keywords: z.string().optional().describe("Space-separated search keywords"), + datasheet: z.string().optional().describe("Datasheet URL or '~'"), + footprint: z + .string() + .optional() + .describe("Default footprint, e.g. 'Package_SO:SOIC-8_3.9x4.9mm_P1.27mm'"), + inBom: z.boolean().optional().describe("Include in BOM (default true)"), + onBoard: z.boolean().optional().describe("Include in netlist for PCB (default true)"), + pins: z + .array(PinSchema) + .optional() + .describe("List of pins (can be empty for graphical-only symbols)"), + rectangles: z + .array(RectSchema) + .optional() + .describe("Body rectangle(s). Typically one rectangle defining the IC body."), + polylines: z + .array(PolylineSchema) + .optional() + .describe("Polyline graphics for custom body shapes (op-amp triangles, etc.)"), + overwrite: z + .boolean() + .optional() + .describe("Replace existing symbol with same name (default false)"), + }, + async (args: { + libraryPath: string; + name: string; + referencePrefix?: string; + description?: string; + keywords?: string; + datasheet?: string; + footprint?: string; + inBom?: boolean; + onBoard?: boolean; + pins?: z.infer[]; + rectangles?: z.infer[]; + polylines?: z.infer[]; + overwrite?: boolean; + }) => { + const result = await callKicadScript("create_symbol", args); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + }, + ); + + // ── delete_symbol ────────────────────────────────────────────────────── // + server.tool( + "delete_symbol", + "Remove a symbol from a .kicad_sym library file.", + { + libraryPath: z.string().describe("Path to the .kicad_sym file"), + name: z.string().describe("Symbol name to delete"), + }, + async (args: { libraryPath: string; name: string }) => { + const result = await callKicadScript("delete_symbol", args); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + }, + ); + + // ── list_symbols_in_library ──────────────────────────────────────────── // + server.tool( + "list_symbols_in_library", + "List all symbol names in a .kicad_sym library file.", + { + libraryPath: z.string().describe("Path to the .kicad_sym file"), + }, + async (args: { libraryPath: string }) => { + const result = await callKicadScript("list_symbols_in_library", args); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + }, + ); + + // ── register_symbol_library ──────────────────────────────────────────── // + server.tool( + "register_symbol_library", + "Register a .kicad_sym library in KiCAD's sym-lib-table so symbols can be used in schematics. " + + "Run this after create_symbol when KiCAD shows 'library not found'.", + { + libraryPath: z + .string() + .describe("Full path to the .kicad_sym file"), + libraryName: z + .string() + .optional() + .describe("Nickname (default: file name without extension)"), + description: z.string().optional(), + scope: z + .enum(["project", "global"]) + .optional() + .describe("project = writes sym-lib-table next to .kicad_pro; global = user config"), + projectPath: z + .string() + .optional() + .describe("Path to .kicad_pro or its directory (for scope=project)"), + }, + async (args: { + libraryPath: string; + libraryName?: string; + description?: string; + scope?: "project" | "global"; + projectPath?: string; + }) => { + const result = await callKicadScript("register_symbol_library", args); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + }, + ); +}