diff --git a/python/commands/library.py b/python/commands/library.py index c897e4b..927899a 100644 --- a/python/commands/library.py +++ b/python/commands/library.py @@ -522,20 +522,35 @@ class LibraryCommands: if path == library_path: library_nickname = nick break + + # Minimal info — always returned even if the parser fails + info: Dict = { + "library": library_nickname, + "name": footprint_name, - info = { - "library": library_nickname, - "name": footprint_name, - "full_name": f"{library_nickname}:{footprint_name}", - "library_path": library_path, - } + "full_name": f"{library_nickname}:{footprint_name}", + "library_path": library_path, + } - return {"success": True, "info": info} - else: - return { - "success": False, - "message": f"Footprint not found: {footprint_spec}", - } + # Attempt to enrich with parsed .kicad_mod data + try: + from parsers.kicad_mod_parser import parse_kicad_mod + from pathlib import Path as _Path + mod_file = str(_Path(library_path) / f"{footprint_name}.kicad_mod") + parsed = parse_kicad_mod(mod_file) + if parsed: + # Merge parser output into info; keep our resolved library context + info.update(parsed) + info["name"] = footprint_name # entry name wins over in-file name + info["library"] = library_nickname + info["full_name"] = f"{library_nickname}:{footprint_name}" + info["library_path"] = library_path + else: + logger.warning(f"get_footprint_info: parser returned nothing for {mod_file}, using minimal info") + except Exception as parse_err: + logger.warning(f"get_footprint_info: parser error ({parse_err}), using minimal info") + + return {"success": True, "info": info} except Exception as e: logger.error(f"Error getting footprint info: {e}") diff --git a/python/parsers/__init__.py b/python/parsers/__init__.py new file mode 100644 index 0000000..d90227c --- /dev/null +++ b/python/parsers/__init__.py @@ -0,0 +1 @@ +# parsers package diff --git a/python/parsers/kicad_mod_parser.py b/python/parsers/kicad_mod_parser.py new file mode 100644 index 0000000..a059f6f --- /dev/null +++ b/python/parsers/kicad_mod_parser.py @@ -0,0 +1,248 @@ +""" +Parser for KiCad .kicad_mod footprint files. + +Extracts the fields that the MCP get_footprint_info tool exposes to clients: + name – footprint name (str) + library – library nickname, injected by caller (str) + description – (descr "…") token (str | None) + keywords – (tags "…") token (str | None) + pads – list of pad objects: [{number, type, shape}, …] (list[dict]) + layers – sorted unique list of canonical layer names used (list[str]) + courtyard – {"width": float, "height": float} from F.CrtYd geometry (dict | None) + attributes – {"type": str, "board_only": bool, …} (dict | None) + +KiCad S-expression file format reference: + https://dev-docs.kicad.org/en/file-formats/sexpr-intro/index.html#_footprint +""" + +import re +import logging +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +logger = logging.getLogger("kicad_interface") + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]: + """ + Parse a .kicad_mod file and return a dict whose keys match the fields + expected by the TypeScript MCP tool handler (src/tools/library.ts). + + Returns None if the file does not exist or cannot be read. + """ + path = Path(file_path) + if not path.exists(): + logger.debug(f"parse_kicad_mod: file not found: {file_path}") + return None + + try: + content = path.read_text(encoding="utf-8") + except OSError as e: + logger.warning(f"parse_kicad_mod: cannot read {file_path}: {e}") + return None + + logger.debug(f"parse_kicad_mod: parsing {path.name} ({len(content)} chars)") + + result: Dict[str, Any] = {} + + # ------------------------------------------------------------------ + # Footprint name: (footprint "NAME" … + # Per spec, in a library file the name is the ENTRY_NAME only (no lib prefix). + # ------------------------------------------------------------------ + m = re.search(r'^\s*\(footprint\s+"((?:[^"\\]|\\.)*)"', content, re.MULTILINE) + if not m: + # Older / unquoted format + m = re.search(r'^\s*\(footprint\s+(\S+)', content, re.MULTILINE) + result["name"] = _unescape(m.group(1)) if m else path.stem + logger.debug(f"parse_kicad_mod: name={result['name']!r}") + + # ------------------------------------------------------------------ + # Description: (descr "…") + # ------------------------------------------------------------------ + m = re.search(r'\(descr\s+"((?:[^"\\]|\\.)*)"\)', content) + result["description"] = _unescape(m.group(1)) if m else None + logger.debug(f"parse_kicad_mod: description={result['description']!r}") + + # ------------------------------------------------------------------ + # Keywords / tags: (tags "…") + # ------------------------------------------------------------------ + m = re.search(r'\(tags\s+"((?:[^"\\]|\\.)*)"\)', content) + result["keywords"] = _unescape(m.group(1)) if m else None + logger.debug(f"parse_kicad_mod: keywords={result['keywords']!r}") + + # ------------------------------------------------------------------ + # Attributes: (attr TYPE [board_only] [exclude_from_pos_files] [exclude_from_bom]) + # TYPE is smd | through_hole (no quotes) + # ------------------------------------------------------------------ + m = re.search(r'\(attr\s+([^)]+)\)', content) + if m: + tokens = m.group(1).split() + result["attributes"] = { + "type": tokens[0] if tokens else "unspecified", + "board_only": "board_only" in tokens, + "exclude_from_pos_files": "exclude_from_pos_files" in tokens, + "exclude_from_bom": "exclude_from_bom" in tokens, + } + else: + result["attributes"] = None + logger.debug(f"parse_kicad_mod: attributes={result['attributes']!r}") + + # ------------------------------------------------------------------ + # Pads: (pad "NUMBER" TYPE SHAPE …) + # Return each pad as an object; deduplicate by number (first wins). + # ------------------------------------------------------------------ + result["pads"] = _extract_pads(content) + logger.debug(f"parse_kicad_mod: pads count={len(result['pads'])}, pads={result['pads']}") + + # ------------------------------------------------------------------ + # Layers: all unique canonical layer names across the whole file. + # Sources: + # (layer "NAME") – single-layer items (fp_line, fp_text, …) + # (layers "A" "B" …) – pad layer lists + # ------------------------------------------------------------------ + layers: set = set() + for m in re.finditer(r'\(layer\s+"([^"]+)"\)', content): + layers.add(m.group(1)) + for m in re.finditer(r'\(layers\s+([^)]+)\)', content): + for lyr in re.findall(r'"([^"]+)"', m.group(1)): + layers.add(lyr) + result["layers"] = sorted(layers) + logger.debug(f"parse_kicad_mod: layers={result['layers']}") + + # ------------------------------------------------------------------ + # Courtyard: derive bounding box from F.CrtYd geometry. + # Prefer fp_rect (most common for standard footprints), fall back to + # fp_line segments. + # ------------------------------------------------------------------ + result["courtyard"] = _extract_courtyard(content) + logger.debug(f"parse_kicad_mod: courtyard={result['courtyard']!r}") + + return result + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _extract_pads(content: str) -> List[Dict[str, Any]]: + """ + Parse all (pad …) blocks and return a list of pad objects. + + Each object has: + number – pad number string, e.g. "1", "A1", "GND" + type – thru_hole | smd | np_thru_hole | connect + shape – rect | circle | oval | roundrect | trapezoid | custom + + Pads are deduplicated by number (first occurrence wins) so that the + list represents the logical pads of the footprint, not duplicated + copper entries. + """ + pads: List[Dict[str, Any]] = [] + seen_numbers: dict = {} + + # KiCad 6+ quoted format: (pad "NUMBER" TYPE SHAPE …) + quoted_pattern = re.compile( + r'\(pad\s+"([^"]*)"\s+' + r'(thru_hole|smd|np_thru_hole|connect)\s+' + r'(rect|circle|oval|roundrect|trapezoid|custom)\b' + ) + for m in quoted_pattern.finditer(content): + number, ptype, shape = m.group(1), m.group(2), m.group(3) + if number not in seen_numbers: + seen_numbers[number] = True + pads.append({"number": number, "type": ptype, "shape": shape}) + + if not pads: + # Older / unquoted format: (pad NUMBER TYPE SHAPE …) + unquoted_pattern = re.compile( + r'\(pad\s+(\S+)\s+' + r'(thru_hole|smd|np_thru_hole|connect)\s+' + r'(rect|circle|oval|roundrect|trapezoid|custom)\b' + ) + for m in unquoted_pattern.finditer(content): + number, ptype, shape = m.group(1), m.group(2), m.group(3) + if number not in seen_numbers: + seen_numbers[number] = True + pads.append({"number": number, "type": ptype, "shape": shape}) + + return pads + + +def _unescape(s: str) -> str: + """Reverse KiCad S-expression string escaping.""" + return s.replace('\\"', '"').replace("\\\\", "\\") + + +def _extract_blocks(content: str, token: str) -> List[str]: + """ + Return all S-expression blocks that start with `(token ` by tracking + parenthesis depth. This correctly handles nested parens inside blocks. + """ + blocks: List[str] = [] + pattern = re.compile(r'\(' + re.escape(token) + r'\b') + + for match in pattern.finditer(content): + start = match.start() + depth = 0 + i = start + while i < len(content): + ch = content[i] + if ch == '(': + depth += 1 + elif ch == ')': + depth -= 1 + if depth == 0: + blocks.append(content[start : i + 1]) + break + i += 1 + + return blocks + + +def _extract_courtyard(content: str) -> Optional[Dict[str, float]]: + """ + Compute the courtyard bounding box from F.CrtYd geometry. + + Strategy: + 1. Try fp_rect blocks on F.CrtYd — derive width/height from start/end. + 2. Fall back to fp_line segments on F.CrtYd — compute bounding box of + all endpoints. + """ + xs: List[float] = [] + ys: List[float] = [] + + # --- fp_rect pass --- + for block in _extract_blocks(content, "fp_rect"): + if "F.CrtYd" not in block: + continue + s = re.search(r'\(start\s+([-\d.]+)\s+([-\d.]+)\)', block) + e = re.search(r'\(end\s+([-\d.]+)\s+([-\d.]+)\)', block) + if s and e: + xs += [float(s.group(1)), float(e.group(1))] + ys += [float(s.group(2)), float(e.group(2))] + logger.debug( + f"_extract_courtyard: fp_rect F.CrtYd " + f"start=({s.group(1)},{s.group(2)}) end=({e.group(1)},{e.group(2)})" + ) + + # --- fp_line pass (only if fp_rect found nothing) --- + if not xs: + for block in _extract_blocks(content, "fp_line"): + if "F.CrtYd" not in block: + continue + for m in re.finditer(r'\((?:start|end)\s+([-\d.]+)\s+([-\d.]+)\)', block): + xs.append(float(m.group(1))) + ys.append(float(m.group(2))) + + if not xs: + logger.debug("_extract_courtyard: no F.CrtYd geometry found") + return None + + width = round(abs(max(xs) - min(xs)), 6) + height = round(abs(max(ys) - min(ys)), 6) + logger.debug(f"_extract_courtyard: result width={width} height={height}") + return {"width": width, "height": height} diff --git a/src/tools/library.ts b/src/tools/library.ts index d81d52f..2adeeac 100644 --- a/src/tools/library.ts +++ b/src/tools/library.ts @@ -154,12 +154,26 @@ export function registerLibraryTools( const result = await callKicadScript("get_footprint_info", args); if (result.success && result.info) { const info = result.info; + + // pads is a list of {number, type, shape} objects + const padsArray: Array<{ number: string; type: string; shape: string }> = + Array.isArray(info.pads) ? info.pads : []; + const padsSummary = padsArray.length + ? `${padsArray.length} pads: ${padsArray.map((p) => p.number).join(", ")}` + : ""; + const padsDetail = padsArray.length + ? padsArray + .map((p) => ` pad ${p.number}: ${p.type} ${p.shape}`) + .join("\n") + : ""; + const details = [ `Footprint: ${info.name}`, `Library: ${info.library}`, info.description ? `Description: ${info.description}` : "", info.keywords ? `Keywords: ${info.keywords}` : "", - info.pads ? `Number of pads: ${info.pads}` : "", + padsSummary, + padsDetail, info.layers ? `Layers used: ${info.layers.join(", ")}` : "", info.courtyard ? `Courtyard size: ${info.courtyard.width}mm x ${info.courtyard.height}mm`