feat: add footprint + symbol creator tools
Footprint tools: - create_footprint: generates .kicad_mod via f-string (SMD/THT, courtyard, silkscreen, fab) - edit_footprint_pad: updates size/position/drill/shape in-place - list_footprint_libraries: scans .pretty directories - register_footprint_library: adds entry to fp-lib-table Symbol tools: - create_symbol: generates .kicad_sym with pins, rectangles, polylines - delete_symbol: removes symbol from library - list_symbols_in_library: lists all symbols in a .kicad_sym file - register_symbol_library: adds entry to sym-lib-table Also: - Fix footprint format version: 20250114 (schematic) -> 20241229 (footprint) - Two MCP prompts: create_footprint_guide + footprint_ipc_checklist Live tested: TMC2209 footprint (19 pads), R_0603_Test, edit_footprint_pad, TMC2209_Custom symbol with pins all confirmed in KiCAD 9
This commit is contained in:
506
python/commands/footprint.py
Normal file
506
python/commands/footprint.py
Normal file
@@ -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 "<pad_number>"" 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' )',
|
||||
"",
|
||||
]
|
||||
Reference in New Issue
Block a user