kicad-mcp/kicad_mcp/tools/export_tools.py
Lama f50a2ce1af Enhance PCB thumbnail generation with robust fallback methods
Implements a more reliable PCB thumbnail generation feature using two methods:
- Primary: pcbnew Python module for high-quality rendering
- Fallback: pcbnew_cli for environments without Python modules

Adds detailed progress reporting and comprehensive error handling.
Includes documentation in docs/thumbnail_guide.md.
2025-03-20 03:16:14 -04:00

427 lines
16 KiB
Python

"""
Analysis and validation tools for KiCad projects.
"""
import os
import tempfile
from typing import Dict, Any, Optional
from mcp.server.fastmcp import FastMCP, Context, Image
from kicad_mcp.utils.file_utils import get_project_files
from kicad_mcp.utils.logger import Logger
# Create logger for this module
logger = Logger()
def register_analysis_tools(mcp: FastMCP, kicad_modules_available: bool = False) -> None:
"""Register analysis and validation tools with the MCP server.
Args:
mcp: The FastMCP server instance
kicad_modules_available: Whether KiCad Python modules are available
"""
@mcp.tool()
def validate_project(project_path: str) -> Dict[str, Any]:
"""Basic validation of a KiCad project."""
logger.info(f"Validating project: {project_path}")
if not os.path.exists(project_path):
logger.error(f"Project not found: {project_path}")
return {"valid": False, "error": f"Project not found: {project_path}"}
issues = []
files = get_project_files(project_path)
# Check for essential files
if "pcb" not in files:
logger.warning("Missing PCB layout file")
issues.append("Missing PCB layout file")
if "schematic" not in files:
logger.warning("Missing schematic file")
issues.append("Missing schematic file")
# Validate project file
try:
with open(project_path, 'r') as f:
import json
json.load(f)
logger.debug("Project file validated successfully")
except json.JSONDecodeError:
logger.error("Invalid project file format (JSON parsing error)")
issues.append("Invalid project file format (JSON parsing error)")
except Exception as e:
logger.error(f"Error reading project file: {str(e)}")
issues.append(f"Error reading project file: {str(e)}")
result = {
"valid": len(issues) == 0,
"path": project_path,
"issues": issues if issues else None,
"files_found": list(files.keys())
}
logger.info(f"Validation result: {'valid' if result['valid'] else 'invalid'}")
return result
@mcp.tool()
async def generate_pcb_thumbnail(project_path: str, ctx: Context) -> Optional[Image]:
"""Generate a thumbnail image of a KiCad PCB layout.
Args:
project_path: Path to the KiCad project file (.kicad_pro)
Returns:
Thumbnail image of the PCB or None if generation failed
"""
logger.info(f"Generating thumbnail for project: {project_path}")
if not os.path.exists(project_path):
logger.error(f"Project not found: {project_path}")
ctx.info(f"Project not found: {project_path}")
return None
# Get PCB file from project
files = get_project_files(project_path)
if "pcb" not in files:
logger.error("PCB file not found in project")
ctx.info("PCB file not found in project")
return None
pcb_file = files["pcb"]
logger.info(f"Found PCB file: {pcb_file}")
await ctx.report_progress(10, 100)
ctx.info(f"Generating thumbnail for {os.path.basename(pcb_file)}")
# Method 1: Try to use pcbnew Python module if available
if kicad_modules_available:
try:
thumbnail = await generate_thumbnail_with_pcbnew(pcb_file, ctx)
if thumbnail:
return thumbnail
# If pcbnew method failed, log it but continue to try alternative method
logger.warning("Failed to generate thumbnail with pcbnew, trying CLI method")
except Exception as e:
logger.error(f"Error using pcbnew for thumbnail: {str(e)}", exc_info=True)
ctx.info(f"Error with pcbnew method, trying alternative approach")
else:
logger.info("KiCad Python modules not available, trying CLI method")
# Method 2: Try to use command-line tools
try:
thumbnail = await generate_thumbnail_with_cli(pcb_file, ctx)
if thumbnail:
return thumbnail
except Exception as e:
logger.error(f"Error using CLI for thumbnail: {str(e)}", exc_info=True)
ctx.info(f"Error generating thumbnail with CLI method")
# If all methods fail, inform the user
ctx.info("Could not generate thumbnail for PCB - all methods failed")
return None
@mcp.tool()
async def generate_project_thumbnail(project_path: str, ctx: Context) -> Optional[Image]:
"""Generate a thumbnail of a KiCad project's PCB layout."""
logger.info(f"Generating thumbnail for project: {project_path}")
if not os.path.exists(project_path):
logger.error(f"Project not found: {project_path}")
ctx.info(f"Project not found: {project_path}")
return None
# Get PCB file
files = get_project_files(project_path)
if "pcb" not in files:
logger.error("PCB file not found in project")
ctx.info("PCB file not found in project")
return None
pcb_file = files["pcb"]
logger.info(f"Found PCB file: {pcb_file}")
if not kicad_modules_available:
logger.warning("KiCad Python modules are not available - cannot generate thumbnail")
ctx.info("KiCad Python modules are not available")
return None
try:
# Try to import pcbnew
import pcbnew
logger.info("Successfully imported pcbnew module")
# Load the PCB file
logger.debug(f"Loading PCB file: {pcb_file}")
board = pcbnew.LoadBoard(pcb_file)
if not board:
logger.error("Failed to load PCB file")
ctx.info("Failed to load PCB file")
return None
# Get board dimensions
board_box = board.GetBoardEdgesBoundingBox()
width = board_box.GetWidth() / 1000000.0 # Convert to mm
height = board_box.GetHeight() / 1000000.0
logger.info(f"PCB dimensions: {width:.2f}mm x {height:.2f}mm")
ctx.info(f"PCB dimensions: {width:.2f}mm x {height:.2f}mm")
# Create temporary directory for output
with tempfile.TemporaryDirectory() as temp_dir:
logger.debug(f"Created temporary directory: {temp_dir}")
# Create PLOT_CONTROLLER for plotting
pctl = pcbnew.PLOT_CONTROLLER(board)
popt = pctl.GetPlotOptions()
# Set plot options for PNG output
popt.SetOutputDirectory(temp_dir)
popt.SetPlotFrameRef(False)
popt.SetPlotValue(True)
popt.SetPlotReference(True)
popt.SetPlotInvisibleText(False)
popt.SetPlotViaOnMaskLayer(False)
popt.SetColorMode(True) # Color mode
# Set color theme (if available in this version)
if hasattr(popt, "SetColorTheme"):
popt.SetColorTheme("default")
# Calculate a reasonable scale to fit in a thumbnail
max_size = 800 # Max pixel dimension
scale = min(max_size / width, max_size / height) * 0.8 # 80% to leave some margin
# Set plot scale if the function exists
if hasattr(popt, "SetScale"):
popt.SetScale(scale)
# Determine output filename
plot_basename = "thumbnail"
output_filename = os.path.join(temp_dir, f"{plot_basename}.png")
logger.debug(f"Plotting PCB to: {output_filename}")
# Plot PNG
pctl.OpenPlotfile(plot_basename, pcbnew.PLOT_FORMAT_PNG, "Thumbnail")
pctl.PlotLayer()
pctl.ClosePlot()
# The plot controller creates files with predictable names
plot_file = os.path.join(temp_dir, f"{plot_basename}.png")
if not os.path.exists(plot_file):
logger.error(f"Expected plot file not found: {plot_file}")
ctx.info("Failed to generate PCB image")
return None
# Read the image file
with open(plot_file, 'rb') as f:
img_data = f.read()
logger.info(f"Successfully generated thumbnail, size: {len(img_data)} bytes")
return Image(data=img_data, format="png")
except ImportError as e:
logger.error(f"Failed to import pcbnew module: {str(e)}")
ctx.info(f"Failed to import pcbnew module: {str(e)}")
return None
except Exception as e:
logger.error(f"Error generating thumbnail: {str(e)}", exc_info=True)
ctx.info(f"Error generating thumbnail: {str(e)}")
return None
# Helper functions for thumbnail generation
async def generate_thumbnail_with_pcbnew(pcb_file: str, ctx: Context) -> Optional[Image]:
"""Generate PCB thumbnail using the pcbnew Python module.
Args:
pcb_file: Path to the PCB file (.kicad_pcb)
ctx: MCP context for progress reporting
Returns:
Image object containing the PCB thumbnail or None if generation failed
"""
try:
import pcbnew
logger.info("Successfully imported pcbnew module")
await ctx.report_progress(20, 100)
# Load the PCB file
logger.debug(f"Loading PCB file with pcbnew: {pcb_file}")
board = pcbnew.LoadBoard(pcb_file)
if not board:
logger.error("Failed to load PCB file with pcbnew")
return None
# Report progress
await ctx.report_progress(30, 100)
ctx.info("PCB file loaded, generating image...")
# Get board dimensions
board_box = board.GetBoardEdgesBoundingBox()
width_mm = board_box.GetWidth() / 1000000.0 # Convert to mm
height_mm = board_box.GetHeight() / 1000000.0
logger.info(f"PCB dimensions: {width_mm:.2f}mm x {height_mm:.2f}mm")
# Create temporary directory for output
with tempfile.TemporaryDirectory() as temp_dir:
logger.debug(f"Created temporary directory: {temp_dir}")
# Create PLOT_CONTROLLER for plotting
pctl = pcbnew.PLOT_CONTROLLER(board)
popt = pctl.GetPlotOptions()
# Set plot options for PNG output
popt.SetOutputDirectory(temp_dir)
popt.SetPlotFrameRef(False)
popt.SetPlotValue(True)
popt.SetPlotReference(True)
popt.SetPlotInvisibleText(False)
popt.SetPlotViaOnMaskLayer(False)
# Set color mode (if available in this version)
if hasattr(popt, "SetColorMode"):
popt.SetColorMode(True) # Color mode
# Set color theme (if available in this version)
if hasattr(popt, "SetColorTheme"):
popt.SetColorTheme("default")
# Calculate a reasonable scale to fit in a thumbnail
max_pixels = 800 # Max pixel dimension
scale = min(max_pixels / width_mm, max_pixels / height_mm) * 0.8 # 80% to leave margin
# Set plot scale if the function exists
if hasattr(popt, "SetScale"):
popt.SetScale(scale)
# Determine output filename
plot_basename = "thumbnail"
logger.debug(f"Plotting PCB to PNG")
await ctx.report_progress(50, 100)
# Plot PNG
pctl.OpenPlotfile(plot_basename, pcbnew.PLOT_FORMAT_PNG, "Thumbnail")
pctl.PlotLayer()
pctl.ClosePlot()
await ctx.report_progress(70, 100)
# The plot controller creates files with predictable names
plot_file = os.path.join(temp_dir, f"{plot_basename}.png")
if not os.path.exists(plot_file):
logger.error(f"Expected plot file not found: {plot_file}")
return None
# Read the image file
with open(plot_file, 'rb') as f:
img_data = f.read()
logger.info(f"Successfully generated thumbnail, size: {len(img_data)} bytes")
await ctx.report_progress(90, 100)
return Image(data=img_data, format="png")
except ImportError as e:
logger.error(f"Failed to import pcbnew module: {str(e)}")
return None
except Exception as e:
logger.error(f"Error generating thumbnail with pcbnew: {str(e)}", exc_info=True)
return None
async def generate_thumbnail_with_cli(pcb_file: str, ctx: Context) -> Optional[Image]:
"""Generate PCB thumbnail using command line tools.
This is a fallback method when pcbnew Python module is not available.
Args:
pcb_file: Path to the PCB file (.kicad_pcb)
ctx: MCP context for progress reporting
Returns:
Image object containing the PCB thumbnail or None if generation failed
"""
import subprocess
logger.info("Attempting to generate thumbnail using command line tools")
await ctx.report_progress(20, 100)
# Check for required command-line tools based on OS
if system == "Darwin": # macOS
pcbnew_cli = os.path.join(KICAD_APP_PATH, "Contents/MacOS/pcbnew_cli")
if not os.path.exists(pcbnew_cli) and shutil.which("pcbnew_cli") is not None:
pcbnew_cli = "pcbnew_cli" # Try to use from PATH
elif not os.path.exists(pcbnew_cli):
logger.error(f"pcbnew_cli not found at {pcbnew_cli} or in PATH")
return None
elif system == "Windows":
pcbnew_cli = os.path.join(KICAD_APP_PATH, "bin", "pcbnew_cli.exe")
if not os.path.exists(pcbnew_cli) and shutil.which("pcbnew_cli") is not None:
pcbnew_cli = "pcbnew_cli" # Try to use from PATH
elif not os.path.exists(pcbnew_cli):
logger.error(f"pcbnew_cli not found at {pcbnew_cli} or in PATH")
return None
elif system == "Linux":
pcbnew_cli = shutil.which("pcbnew_cli")
if not pcbnew_cli:
logger.error("pcbnew_cli not found in PATH")
return None
else:
logger.error(f"Unsupported operating system: {system}")
return None
await ctx.report_progress(30, 100)
ctx.info("Using KiCad command line tools for thumbnail generation")
# Create temporary directory for output
with tempfile.TemporaryDirectory() as temp_dir:
# Output PNG file
output_file = os.path.join(temp_dir, "thumbnail.png")
# Build command for generating PNG from PCB
cmd = [
pcbnew_cli,
"--export-png",
output_file,
"--page-size-inches", "8x6", # Set a reasonable page size
"--layers", "F.Cu,B.Cu,F.SilkS,B.SilkS,F.Mask,B.Mask,Edge.Cuts", # Important layers
pcb_file
]
logger.debug(f"Running command: {' '.join(cmd)}")
await ctx.report_progress(50, 100)
# Run the command
try:
process = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if process.returncode != 0:
logger.error(f"Command failed with code {process.returncode}")
logger.error(f"Error: {process.stderr}")
return None
await ctx.report_progress(70, 100)
# Check if the output file was created
if not os.path.exists(output_file):
logger.error(f"Output file not created: {output_file}")
return None
# Read the image file
with open(output_file, 'rb') as f:
img_data = f.read()
logger.info(f"Successfully generated thumbnail with CLI, size: {len(img_data)} bytes")
await ctx.report_progress(90, 100)
return Image(data=img_data, format="png")
except subprocess.TimeoutExpired:
logger.error("Command timed out after 30 seconds")
return None
except Exception as e:
logger.error(f"Error running CLI command: {str(e)}", exc_info=True)
return None