fix(board-view): rebase onto main — fold responseMode + restore jpg
Rebased onto current main (d765bfe). Merged changes:
Board view (kicad-cli, cffi-free):
- Replace pcbnew/PLOT_CONTROLLER + cairosvg with kicad-cli SVG export
and cffi-free PNG conversion: pymupdf → inkscape → imagemagick chain
- pcbPath optional param with fallback to loaded board
- Restore jpg output via PIL post-processing on PNG bytes
- responseMode inline/file from main's #161: inline returns imageData,
file writes <board>_2d_view.<ext> and returns filePath
- shutil.which for cross-platform kicad-cli lookup
- Explicit TimeoutExpired catch; errorDetails on all error returns
- Validate fmt strictly; TypeScript returns image type for inline png/jpg
Schematic view (kicad_interface.py):
- Same cffi-free _svg_to_png helper for get_schematic_view and
get_schematic_view_region (pymupdf → inkscape → imagemagick)
Tests:
- Update test_get_board_2d_view_save_to_file.py to mock subprocess/
kicad-cli and _svg_to_png instead of PLOT_CONTROLLER/cairosvg
- All 5 responseMode tests pass
This commit is contained in:
@@ -14,6 +14,64 @@ from PIL import Image
|
|||||||
logger = logging.getLogger("kicad_interface")
|
logger = logging.getLogger("kicad_interface")
|
||||||
|
|
||||||
|
|
||||||
|
def _svg_to_png(svg_path: str, width: int, height: int) -> Optional[bytes]:
|
||||||
|
"""Convert SVG to PNG. No cffi dependency.
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
1. pymupdf (fitz) — bundled MuPDF renderer, pure Python, no system deps
|
||||||
|
2. Inkscape CLI — accurate KiCAD SVG rendering
|
||||||
|
3. ImageMagick convert — broad availability fallback
|
||||||
|
Returns PNG bytes or None if all converters fail.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
try:
|
||||||
|
import fitz
|
||||||
|
|
||||||
|
doc = fitz.open(svg_path)
|
||||||
|
page = doc[0]
|
||||||
|
mat = fitz.Matrix(width / page.rect.width, height / page.rect.height)
|
||||||
|
return page.get_pixmap(matrix=mat).tobytes("png")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
out_path = os.path.join(tempfile.mkdtemp(), "out.png")
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
[
|
||||||
|
"inkscape",
|
||||||
|
svg_path,
|
||||||
|
"--export-type=png",
|
||||||
|
f"--export-width={width}",
|
||||||
|
f"--export-height={height}",
|
||||||
|
f"--export-filename={out_path}",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if r.returncode == 0 and os.path.exists(out_path):
|
||||||
|
with open(out_path, "rb") as f:
|
||||||
|
return f.read()
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["convert", "-density", "150", svg_path, "-resize", f"{width}x{height}", out_path],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if r.returncode == 0 and os.path.exists(out_path):
|
||||||
|
with open(out_path, "rb") as f:
|
||||||
|
return f.read()
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class BoardViewCommands:
|
class BoardViewCommands:
|
||||||
"""Handles board viewing operations"""
|
"""Handles board viewing operations"""
|
||||||
|
|
||||||
@@ -73,85 +131,115 @@ class BoardViewCommands:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Get a 2D image of the PCB.
|
"""Render PCB to PNG/JPG/SVG via kicad-cli (no pcbnew/cffi dependency).
|
||||||
|
|
||||||
responseMode controls how the image is returned:
|
responseMode controls how the image is returned:
|
||||||
- "inline" (default): image bytes are base64-encoded and returned as ``imageData``.
|
- "inline" (default): image bytes are base64-encoded and returned as ``imageData``.
|
||||||
- "file": image is written next to the .kicad_pcb file and ``filePath`` is returned.
|
- "file": image is written next to the .kicad_pcb file and ``filePath`` is returned.
|
||||||
"""
|
"""
|
||||||
|
import glob
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self.board:
|
pcb_path = params.get("pcbPath")
|
||||||
|
if not pcb_path:
|
||||||
|
if self.board:
|
||||||
|
pcb_path = self.board.GetFileName()
|
||||||
|
if not pcb_path:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "No board is loaded",
|
"message": "pcbPath required",
|
||||||
"errorDetails": "Load or create a board first",
|
"errorDetails": "Provide pcbPath or load a board first",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get parameters
|
if not os.path.exists(pcb_path):
|
||||||
width = params.get("width", 800)
|
return {
|
||||||
height = params.get("height", 600)
|
"success": False,
|
||||||
format = params.get("format", "png")
|
"message": f"PCB file not found: {pcb_path}",
|
||||||
layers = params.get("layers", [])
|
"errorDetails": pcb_path,
|
||||||
|
}
|
||||||
|
|
||||||
|
width = params.get("width", 1600)
|
||||||
|
height = params.get("height", 1200)
|
||||||
|
fmt = params.get("format", "png")
|
||||||
|
if fmt not in ("png", "jpg", "svg"):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"Unsupported format '{fmt}'. Use 'png', 'jpg', or 'svg'.",
|
||||||
|
"errorDetails": f"Got: {fmt}",
|
||||||
|
}
|
||||||
|
layers: List[str] = params.get("layers", [])
|
||||||
response_mode = params.get("responseMode", "inline")
|
response_mode = params.get("responseMode", "inline")
|
||||||
|
|
||||||
# Create plot controller
|
kicad_cli = shutil.which("kicad-cli") or shutil.which("kicad-cli.exe")
|
||||||
plotter = pcbnew.PLOT_CONTROLLER(self.board)
|
if not kicad_cli:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "kicad-cli not found in PATH",
|
||||||
|
"errorDetails": "Install KiCad and ensure kicad-cli is on PATH",
|
||||||
|
}
|
||||||
|
|
||||||
# Set up plot options
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
plot_opts = plotter.GetPlotOptions()
|
cmd = [kicad_cli, "pcb", "export", "svg", "--output", tmpdir, "--black-and-white"]
|
||||||
plot_opts.SetOutputDirectory(os.path.dirname(self.board.GetFileName()))
|
|
||||||
plot_opts.SetScale(1)
|
|
||||||
plot_opts.SetMirror(False)
|
|
||||||
# Note: SetExcludeEdgeLayer() removed in KiCAD 9.0 - default behavior includes all layers
|
|
||||||
plot_opts.SetPlotFrameRef(False)
|
|
||||||
plot_opts.SetPlotValue(True)
|
|
||||||
plot_opts.SetPlotReference(True)
|
|
||||||
|
|
||||||
# Plot to SVG first (for vector output)
|
|
||||||
# Note: KiCAD 9.0 prepends the project name to the filename, so we use GetPlotFileName() to get the actual path
|
|
||||||
plotter.OpenPlotfile("temp_view", pcbnew.PLOT_FORMAT_SVG, "Temporary View")
|
|
||||||
|
|
||||||
# Plot specified layers or all enabled layers
|
|
||||||
# Note: In KiCAD 9.0, SetLayer() must be called before PlotLayer()
|
|
||||||
if layers:
|
if layers:
|
||||||
for layer_name in layers:
|
cmd += ["--layers", ",".join(layers)]
|
||||||
layer_id = self.board.GetLayerID(layer_name)
|
cmd.append(pcb_path)
|
||||||
if layer_id >= 0 and self.board.IsLayerEnabled(layer_id):
|
|
||||||
plotter.SetLayer(layer_id)
|
|
||||||
plotter.PlotLayer()
|
|
||||||
else:
|
|
||||||
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
|
|
||||||
if self.board.IsLayerEnabled(layer_id):
|
|
||||||
plotter.SetLayer(layer_id)
|
|
||||||
plotter.PlotLayer()
|
|
||||||
|
|
||||||
# Get the actual filename that was created (includes project name prefix)
|
try:
|
||||||
temp_svg = plotter.GetPlotFileName()
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "kicad-cli timed out after 60 s",
|
||||||
|
"errorDetails": " ".join(cmd),
|
||||||
|
}
|
||||||
|
|
||||||
plotter.ClosePlot()
|
if result.returncode != 0:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "kicad-cli SVG export failed",
|
||||||
|
"errorDetails": result.stderr.strip() or result.stdout.strip(),
|
||||||
|
}
|
||||||
|
|
||||||
# Determine output path next to the PCB file
|
svg_files = glob.glob(os.path.join(tmpdir, "*.svg"))
|
||||||
board_dir = os.path.dirname(self.board.GetFileName())
|
if not svg_files:
|
||||||
board_name = os.path.splitext(os.path.basename(self.board.GetFileName()))[0]
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "kicad-cli produced no SVG output",
|
||||||
|
"errorDetails": result.stdout.strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
svg_path = svg_files[0]
|
||||||
|
|
||||||
# --- Render to bytes (shared for both response modes) ---
|
# --- Render to bytes (shared for both response modes) ---
|
||||||
if format == "svg":
|
board_dir = os.path.dirname(pcb_path)
|
||||||
with open(temp_svg, "rb") as f:
|
board_name = os.path.splitext(os.path.basename(pcb_path))[0]
|
||||||
|
|
||||||
|
if fmt == "svg":
|
||||||
|
with open(svg_path, "rb") as f:
|
||||||
image_bytes = f.read()
|
image_bytes = f.read()
|
||||||
os.remove(temp_svg)
|
|
||||||
mime_format = "svg"
|
mime_format = "svg"
|
||||||
else:
|
else:
|
||||||
from cairosvg import svg2png
|
png_bytes = _svg_to_png(svg_path, width, height)
|
||||||
|
if png_bytes is None:
|
||||||
image_bytes = svg2png(url=temp_svg, output_width=width, output_height=height)
|
# No PNG converter — fall back to SVG inline
|
||||||
os.remove(temp_svg)
|
with open(svg_path, "r", encoding="utf-8") as f:
|
||||||
|
return {
|
||||||
if format == "jpg":
|
"success": True,
|
||||||
img = Image.open(io.BytesIO(image_bytes))
|
"format": "svg",
|
||||||
|
"imageData": base64.b64encode(f.read().encode()).decode("utf-8"),
|
||||||
|
"message": "No PNG converter available — returning SVG. Install pymupdf, inkscape, or imagemagick.",
|
||||||
|
}
|
||||||
|
if fmt == "jpg":
|
||||||
|
img = Image.open(io.BytesIO(png_bytes))
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
img.convert("RGB").save(buf, format="JPEG")
|
img.convert("RGB").save(buf, format="JPEG")
|
||||||
image_bytes = buf.getvalue()
|
image_bytes = buf.getvalue()
|
||||||
mime_format = format
|
else:
|
||||||
|
image_bytes = png_bytes
|
||||||
|
mime_format = fmt
|
||||||
|
|
||||||
# --- Package response according to responseMode ---
|
# --- Package response according to responseMode ---
|
||||||
if response_mode == "file":
|
if response_mode == "file":
|
||||||
@@ -165,16 +253,20 @@ class BoardViewCommands:
|
|||||||
"message": f"2D view saved to {output_path}",
|
"message": f"2D view saved to {output_path}",
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
# inline mode: base64-encode and return imageData
|
|
||||||
image_b64 = base64.b64encode(image_bytes).decode("utf-8")
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"format": mime_format,
|
"format": mime_format,
|
||||||
"imageData": image_b64,
|
"imageData": base64.b64encode(image_bytes).decode("utf-8"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "kicad-cli not found in PATH",
|
||||||
|
"errorDetails": "Install KiCad and ensure kicad-cli is on PATH",
|
||||||
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting board 2D view: {str(e)}")
|
logger.error(f"Error getting board 2D view: {e}")
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Failed to get board 2D view",
|
"message": "Failed to get board 2D view",
|
||||||
|
|||||||
@@ -271,6 +271,64 @@ except ImportError as e:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _svg_to_png(svg_path: str, width: int, height: int) -> Optional[bytes]:
|
||||||
|
"""Convert SVG to PNG. No cffi dependency.
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
1. pymupdf (fitz) — bundled MuPDF renderer, pure Python, no system deps
|
||||||
|
2. Inkscape CLI — accurate KiCAD SVG rendering
|
||||||
|
3. ImageMagick convert — broad availability fallback
|
||||||
|
Returns PNG bytes or None if all converters fail.
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
try:
|
||||||
|
import fitz
|
||||||
|
|
||||||
|
doc = fitz.open(svg_path)
|
||||||
|
page = doc[0]
|
||||||
|
mat = fitz.Matrix(width / page.rect.width, height / page.rect.height)
|
||||||
|
return page.get_pixmap(matrix=mat).tobytes("png")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
out_path = os.path.join(tempfile.mkdtemp(), "out.png")
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
[
|
||||||
|
"inkscape",
|
||||||
|
svg_path,
|
||||||
|
"--export-type=png",
|
||||||
|
f"--export-width={width}",
|
||||||
|
f"--export-height={height}",
|
||||||
|
f"--export-filename={out_path}",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if r.returncode == 0 and os.path.exists(out_path):
|
||||||
|
with open(out_path, "rb") as f:
|
||||||
|
return f.read()
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["convert", "-density", "150", svg_path, "-resize", f"{width}x{height}", out_path],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if r.returncode == 0 and os.path.exists(out_path):
|
||||||
|
with open(out_path, "rb") as f:
|
||||||
|
return f.read()
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class KiCADInterface:
|
class KiCADInterface:
|
||||||
"""Main interface class to handle KiCAD operations"""
|
"""Main interface class to handle KiCAD operations"""
|
||||||
|
|
||||||
@@ -2898,22 +2956,18 @@ class KiCADInterface:
|
|||||||
svg_data = f.read()
|
svg_data = f.read()
|
||||||
return {"success": True, "imageData": svg_data, "format": "svg"}
|
return {"success": True, "imageData": svg_data, "format": "svg"}
|
||||||
|
|
||||||
# Step 2: Convert SVG to PNG using cairosvg
|
# Step 2: Convert SVG to PNG (cffi-free)
|
||||||
try:
|
png_data = _svg_to_png(svg_path, width, height)
|
||||||
from cairosvg import svg2png
|
if png_data is None:
|
||||||
except ImportError:
|
|
||||||
# Fallback: return SVG data with a note
|
|
||||||
with open(svg_path, "r", encoding="utf-8") as f:
|
with open(svg_path, "r", encoding="utf-8") as f:
|
||||||
svg_data = f.read()
|
svg_data = f.read()
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"imageData": svg_data,
|
"imageData": svg_data,
|
||||||
"format": "svg",
|
"format": "svg",
|
||||||
"message": "cairosvg not installed — returning SVG instead of PNG. Install with: pip install cairosvg",
|
"message": "No PNG converter available — returning SVG. Install pymupdf, inkscape, or imagemagick.",
|
||||||
}
|
}
|
||||||
|
|
||||||
png_data = svg2png(url=svg_path, output_width=width, output_height=height)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"imageData": base64.b64encode(png_data).decode("utf-8"),
|
"imageData": base64.b64encode(png_data).decode("utf-8"),
|
||||||
@@ -4871,16 +4925,12 @@ class KiCADInterface:
|
|||||||
svg_data = f.read()
|
svg_data = f.read()
|
||||||
return {"success": True, "imageData": svg_data, "format": "svg"}
|
return {"success": True, "imageData": svg_data, "format": "svg"}
|
||||||
else:
|
else:
|
||||||
try:
|
png_data = _svg_to_png(cropped_svg_path, width, height)
|
||||||
from cairosvg import svg2png
|
if png_data is None:
|
||||||
except ImportError:
|
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "PNG export requires the 'cairosvg' package. Install it with: pip install cairosvg",
|
"message": "No PNG converter available. Install pymupdf, inkscape, or imagemagick.",
|
||||||
}
|
}
|
||||||
png_data = svg2png(
|
|
||||||
url=cropped_svg_path, output_width=width, output_height=height
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"imageData": base64.b64encode(png_data).decode("utf-8"),
|
"imageData": base64.b64encode(png_data).decode("utf-8"),
|
||||||
|
|||||||
@@ -229,29 +229,32 @@ BOARD_TOOLS = [
|
|||||||
"name": "get_board_2d_view",
|
"name": "get_board_2d_view",
|
||||||
"title": "Render Board Preview",
|
"title": "Render Board Preview",
|
||||||
"description": (
|
"description": (
|
||||||
"Generates a 2D visual representation of the current board state as a PNG, JPG, or SVG image. "
|
"Renders a 2D image of the PCB via kicad-cli (no pcbnew/cffi dependency). "
|
||||||
|
"Supports PNG, JPG, and SVG output. "
|
||||||
"Use responseMode to control how the image is returned. "
|
"Use responseMode to control how the image is returned. "
|
||||||
'responseMode="inline" (default) returns the image bytes as a base64-encoded imageData '
|
'responseMode="inline" (default) returns the image as base64-encoded imageData — '
|
||||||
"string in the JSON response — convenient for small boards but may exceed message-size limits on "
|
"convenient for small boards but may exceed message-size limits on large designs. "
|
||||||
"large designs. "
|
|
||||||
'responseMode="file" writes the image next to the .kicad_pcb file as '
|
'responseMode="file" writes the image next to the .kicad_pcb file as '
|
||||||
"<board>_2d_view.<ext> and returns a filePath; callers that can open local files should "
|
"<board>_2d_view.<ext> and returns a filePath; prefer this mode for large boards."
|
||||||
"prefer this mode for large boards."
|
|
||||||
),
|
),
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"pcbPath": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Absolute path to the .kicad_pcb file. Falls back to the currently loaded board if omitted.",
|
||||||
|
},
|
||||||
"width": {
|
"width": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"description": "Image width in pixels (default: 800)",
|
"description": "Image width in pixels (default: 1600)",
|
||||||
"minimum": 100,
|
"minimum": 100,
|
||||||
"default": 800,
|
"default": 1600,
|
||||||
},
|
},
|
||||||
"height": {
|
"height": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"description": "Image height in pixels (default: 600)",
|
"description": "Image height in pixels (default: 1200)",
|
||||||
"minimum": 100,
|
"minimum": 100,
|
||||||
"default": 600,
|
"default": 1200,
|
||||||
},
|
},
|
||||||
"format": {
|
"format": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -262,7 +265,7 @@ BOARD_TOOLS = [
|
|||||||
"layers": {
|
"layers": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {"type": "string"},
|
"items": {"type": "string"},
|
||||||
"description": "Optional list of layer names to include; all enabled layers if omitted",
|
"description": "Layer names to include, e.g. [\"F.Cu\",\"B.Cu\",\"Edge.Cuts\"]. Omit for all layers.",
|
||||||
},
|
},
|
||||||
"responseMode": {
|
"responseMode": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -270,7 +273,7 @@ BOARD_TOOLS = [
|
|||||||
"default": "inline",
|
"default": "inline",
|
||||||
"description": (
|
"description": (
|
||||||
"How to return the image. "
|
"How to return the image. "
|
||||||
'"inline" (default): base64-encoded bytes in the imageData response field. '
|
'"inline" (default): base64-encoded bytes in imageData. '
|
||||||
'"file": write to <board>_2d_view.<ext> next to the PCB and return filePath.'
|
'"file": write to <board>_2d_view.<ext> next to the PCB and return filePath.'
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -363,27 +363,30 @@ export function registerBoardTools(server: McpServer, callKicadScript: CommandFu
|
|||||||
server.tool(
|
server.tool(
|
||||||
"get_board_2d_view",
|
"get_board_2d_view",
|
||||||
[
|
[
|
||||||
"Render a 2D image of the current PCB board and return it as PNG, JPG or SVG.",
|
"Render a 2D image of the PCB using kicad-cli. Returns PNG, JPG, or SVG.",
|
||||||
"Use responseMode to choose how the image is delivered:",
|
"Use layers to filter — e.g. [\"F.Cu\",\"B.Cu\",\"Edge.Cuts\"] for copper + outline only.",
|
||||||
' "inline" (default) — base64-encoded bytes returned in imageData; works well for small boards.',
|
"Use responseMode to choose delivery:",
|
||||||
|
' "inline" (default) — PNG/JPG rendered as an image visible to Claude; SVG returned as text.',
|
||||||
' "file" — image written next to the .kicad_pcb as <board>_2d_view.<ext>; filePath is returned.',
|
' "file" — image written next to the .kicad_pcb as <board>_2d_view.<ext>; filePath is returned.',
|
||||||
"Use file mode for large boards to avoid hitting MCP message-size limits.",
|
"Use file mode for large boards to avoid MCP message-size limits.",
|
||||||
].join(" "),
|
].join(" "),
|
||||||
{
|
{
|
||||||
layers: z.array(z.string()).optional().describe("Optional array of layer names to include"),
|
pcbPath: z.string().optional().describe("Absolute path to the .kicad_pcb file. Falls back to the currently loaded board if omitted."),
|
||||||
width: z.number().optional().describe("Optional width of the image in pixels"),
|
layers: z.array(z.string()).optional().describe("Layer names to include, e.g. [\"F.Cu\",\"B.Cu\",\"Edge.Cuts\"]. Omit for all layers."),
|
||||||
height: z.number().optional().describe("Optional height of the image in pixels"),
|
width: z.number().optional().describe("Output image width in pixels (default: 1600)"),
|
||||||
format: z.enum(["png", "jpg", "svg"]).optional().describe("Image format"),
|
height: z.number().optional().describe("Output image height in pixels (default: 1200)"),
|
||||||
|
format: z.enum(["png", "jpg", "svg"]).optional().describe("Output format (default: png)"),
|
||||||
responseMode: z
|
responseMode: z
|
||||||
.enum(["inline", "file"])
|
.enum(["inline", "file"])
|
||||||
.optional()
|
.optional()
|
||||||
.describe(
|
.describe(
|
||||||
'How to return the image: "inline" (default) returns base64 imageData; "file" writes to disk and returns filePath',
|
'"inline" (default): image returned directly; "file": written to disk, filePath returned',
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
async ({ layers, width, height, format, responseMode }) => {
|
async ({ pcbPath, layers, width, height, format, responseMode }) => {
|
||||||
logger.debug("Getting 2D board view");
|
logger.debug("Getting 2D board view");
|
||||||
const result = await callKicadScript("get_board_2d_view", {
|
const result = await callKicadScript("get_board_2d_view", {
|
||||||
|
pcbPath,
|
||||||
layers,
|
layers,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
@@ -391,14 +394,40 @@ export function registerBoardTools(server: McpServer, callKicadScript: CommandFu
|
|||||||
responseMode,
|
responseMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// file mode — just return the path as text
|
||||||
|
if (responseMode === "file" || result.filePath) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: result.message || result.filePath }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// inline svg (or fallback svg) — return as text, prepend any notice
|
||||||
|
if (result.format === "svg") {
|
||||||
|
const parts: { type: "text"; text: string }[] = [];
|
||||||
|
if (result.message) parts.push({ type: "text" as const, text: result.message });
|
||||||
|
parts.push({ type: "text" as const, text: Buffer.from(result.imageData, "base64").toString("utf-8") });
|
||||||
|
return { content: parts };
|
||||||
|
}
|
||||||
|
// inline png/jpg — return as renderable image
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "image" as const,
|
||||||
text: JSON.stringify(result),
|
data: result.imageData,
|
||||||
|
mimeType: result.format === "jpg" ? "image/jpeg" : "image/png",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Failed to get board view: ${result.message || result.errorDetails || "Unknown error"}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ Two modes are supported:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
@@ -17,14 +18,16 @@ import pytest
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
|
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Shared helpers
|
# Shared helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_FAKE_SVG = b"<svg><rect/></svg>"
|
||||||
|
_FAKE_PNG = b"\x89PNG\r\n\x1a\n" + b"fakepngbytes"
|
||||||
|
|
||||||
|
|
||||||
def _make_view_cmd(tmp_path: Path):
|
def _make_view_cmd(tmp_path: Path):
|
||||||
"""Build a BoardViewCommands instance with a fake board."""
|
"""Build a BoardViewCommands instance with a real PCB file and optional fake board."""
|
||||||
from commands.board.view import BoardViewCommands
|
from commands.board.view import BoardViewCommands
|
||||||
|
|
||||||
board_path = tmp_path / "MyBoard.kicad_pcb"
|
board_path = tmp_path / "MyBoard.kicad_pcb"
|
||||||
@@ -32,28 +35,24 @@ def _make_view_cmd(tmp_path: Path):
|
|||||||
|
|
||||||
fake_board = MagicMock()
|
fake_board = MagicMock()
|
||||||
fake_board.GetFileName.return_value = str(board_path)
|
fake_board.GetFileName.return_value = str(board_path)
|
||||||
return BoardViewCommands(board=fake_board), tmp_path
|
return BoardViewCommands(board=fake_board), tmp_path, board_path
|
||||||
|
|
||||||
|
|
||||||
def _patched_plotter(temp_svg: Path):
|
def _patch_kicad_cli(svg_dir: Path):
|
||||||
"""Context manager that stubs PLOT_CONTROLLER to return the given temp SVG path."""
|
"""Stubs that make kicad-cli appear to succeed and produce an SVG in svg_dir."""
|
||||||
plotter = MagicMock()
|
svg_path = svg_dir / "MyBoard.svg"
|
||||||
plotter.GetPlotFileName.return_value = str(temp_svg)
|
svg_path.write_bytes(_FAKE_SVG)
|
||||||
plotter.OpenPlotfile.return_value = True
|
|
||||||
plotter.GetPlotOptions.return_value = MagicMock()
|
|
||||||
return patch("commands.board.view.pcbnew.PLOT_CONTROLLER", return_value=plotter)
|
|
||||||
|
|
||||||
|
fake_result = MagicMock()
|
||||||
|
fake_result.returncode = 0
|
||||||
|
fake_result.stderr = ""
|
||||||
|
fake_result.stdout = ""
|
||||||
|
|
||||||
_FAKE_SVG = b"<svg><rect/></svg>"
|
return (
|
||||||
_FAKE_PNG = b"\x89PNG\r\n\x1a\n" + b"fakepngbytes"
|
patch("shutil.which", return_value="/usr/bin/kicad-cli"),
|
||||||
|
patch("subprocess.run", return_value=fake_result),
|
||||||
|
patch("glob.glob", return_value=[str(svg_path)]),
|
||||||
def _install_cairosvg_stub(png_bytes: bytes) -> None:
|
)
|
||||||
"""Inject a cairosvg stub into sys.modules if the real library is absent."""
|
|
||||||
if "cairosvg" not in sys.modules:
|
|
||||||
stub = MagicMock()
|
|
||||||
stub.svg2png.return_value = png_bytes
|
|
||||||
sys.modules["cairosvg"] = stub
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -64,58 +63,48 @@ def _install_cairosvg_stub(png_bytes: bytes) -> None:
|
|||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_inline_png_returns_base64_image_data(tmp_path):
|
def test_inline_png_returns_base64_image_data(tmp_path):
|
||||||
"""responseMode='inline' with format='png' returns valid base64 in imageData."""
|
"""responseMode='inline' with format='png' returns valid base64 in imageData."""
|
||||||
cmd, root = _make_view_cmd(tmp_path)
|
cmd, root, board_path = _make_view_cmd(tmp_path)
|
||||||
temp_svg = root / "scratch.svg"
|
w1, w2, w3 = _patch_kicad_cli(root)
|
||||||
temp_svg.write_bytes(_FAKE_SVG)
|
|
||||||
_install_cairosvg_stub(_FAKE_PNG)
|
|
||||||
|
|
||||||
with (
|
with w1, w2, w3, patch("commands.board.view._svg_to_png", return_value=_FAKE_PNG):
|
||||||
_patched_plotter(temp_svg),
|
result = cmd.get_board_2d_view(
|
||||||
patch("cairosvg.svg2png", return_value=_FAKE_PNG, create=True),
|
{"pcbPath": str(board_path), "format": "png", "responseMode": "inline"}
|
||||||
):
|
)
|
||||||
result = cmd.get_board_2d_view({"format": "png", "responseMode": "inline"})
|
|
||||||
|
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
assert result["format"] == "png"
|
assert result["format"] == "png"
|
||||||
assert "imageData" in result
|
assert "imageData" in result
|
||||||
decoded = base64.b64decode(result["imageData"])
|
assert base64.b64decode(result["imageData"]) == _FAKE_PNG
|
||||||
assert decoded == _FAKE_PNG
|
|
||||||
# No file should have been written for inline mode
|
|
||||||
assert not (root / "MyBoard_2d_view.png").exists()
|
assert not (root / "MyBoard_2d_view.png").exists()
|
||||||
assert "filePath" not in result
|
assert "filePath" not in result
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_inline_svg_returns_base64_image_data(tmp_path):
|
def test_inline_svg_returns_base64_image_data(tmp_path):
|
||||||
"""responseMode='inline' with format='svg' returns valid base64 in imageData."""
|
"""responseMode='inline' with format='svg' returns base64-encoded SVG in imageData."""
|
||||||
cmd, root = _make_view_cmd(tmp_path)
|
cmd, root, board_path = _make_view_cmd(tmp_path)
|
||||||
temp_svg = root / "scratch.svg"
|
w1, w2, w3 = _patch_kicad_cli(root)
|
||||||
temp_svg.write_bytes(_FAKE_SVG)
|
|
||||||
|
|
||||||
with _patched_plotter(temp_svg):
|
with w1, w2, w3:
|
||||||
result = cmd.get_board_2d_view({"format": "svg", "responseMode": "inline"})
|
result = cmd.get_board_2d_view(
|
||||||
|
{"pcbPath": str(board_path), "format": "svg", "responseMode": "inline"}
|
||||||
|
)
|
||||||
|
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
assert result["format"] == "svg"
|
assert result["format"] == "svg"
|
||||||
assert "imageData" in result
|
assert "imageData" in result
|
||||||
decoded = base64.b64decode(result["imageData"])
|
assert base64.b64decode(result["imageData"]) == _FAKE_SVG
|
||||||
assert decoded == _FAKE_SVG
|
|
||||||
assert "filePath" not in result
|
assert "filePath" not in result
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_default_response_mode_is_inline(tmp_path):
|
def test_default_response_mode_is_inline(tmp_path):
|
||||||
"""Omitting responseMode defaults to inline behavior (imageData, no filePath)."""
|
"""Omitting responseMode defaults to inline behavior (imageData, no filePath)."""
|
||||||
cmd, root = _make_view_cmd(tmp_path)
|
cmd, root, board_path = _make_view_cmd(tmp_path)
|
||||||
temp_svg = root / "scratch.svg"
|
w1, w2, w3 = _patch_kicad_cli(root)
|
||||||
temp_svg.write_bytes(_FAKE_SVG)
|
|
||||||
_install_cairosvg_stub(_FAKE_PNG)
|
|
||||||
|
|
||||||
with (
|
with w1, w2, w3, patch("commands.board.view._svg_to_png", return_value=_FAKE_PNG):
|
||||||
_patched_plotter(temp_svg),
|
result = cmd.get_board_2d_view({"pcbPath": str(board_path), "format": "png"})
|
||||||
patch("cairosvg.svg2png", return_value=_FAKE_PNG, create=True),
|
|
||||||
):
|
|
||||||
result = cmd.get_board_2d_view({"format": "png"})
|
|
||||||
|
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
assert "imageData" in result
|
assert "imageData" in result
|
||||||
@@ -130,12 +119,13 @@ def test_default_response_mode_is_inline(tmp_path):
|
|||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_file_mode_svg_writes_file_and_returns_path(tmp_path):
|
def test_file_mode_svg_writes_file_and_returns_path(tmp_path):
|
||||||
"""responseMode='file' with format='svg' writes the file and returns filePath."""
|
"""responseMode='file' with format='svg' writes the file and returns filePath."""
|
||||||
cmd, root = _make_view_cmd(tmp_path)
|
cmd, root, board_path = _make_view_cmd(tmp_path)
|
||||||
temp_svg = root / "scratch.svg"
|
w1, w2, w3 = _patch_kicad_cli(root)
|
||||||
temp_svg.write_bytes(_FAKE_SVG)
|
|
||||||
|
|
||||||
with _patched_plotter(temp_svg):
|
with w1, w2, w3:
|
||||||
result = cmd.get_board_2d_view({"format": "svg", "responseMode": "file"})
|
result = cmd.get_board_2d_view(
|
||||||
|
{"pcbPath": str(board_path), "format": "svg", "responseMode": "file"}
|
||||||
|
)
|
||||||
|
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
assert result["format"] == "svg"
|
assert result["format"] == "svg"
|
||||||
@@ -149,16 +139,13 @@ def test_file_mode_svg_writes_file_and_returns_path(tmp_path):
|
|||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_file_mode_png_writes_file_and_returns_path(tmp_path):
|
def test_file_mode_png_writes_file_and_returns_path(tmp_path):
|
||||||
"""responseMode='file' with format='png' writes the PNG and returns filePath."""
|
"""responseMode='file' with format='png' writes the PNG and returns filePath."""
|
||||||
cmd, root = _make_view_cmd(tmp_path)
|
cmd, root, board_path = _make_view_cmd(tmp_path)
|
||||||
temp_svg = root / "scratch.svg"
|
w1, w2, w3 = _patch_kicad_cli(root)
|
||||||
temp_svg.write_bytes(_FAKE_SVG)
|
|
||||||
_install_cairosvg_stub(_FAKE_PNG)
|
|
||||||
|
|
||||||
with (
|
with w1, w2, w3, patch("commands.board.view._svg_to_png", return_value=_FAKE_PNG):
|
||||||
_patched_plotter(temp_svg),
|
result = cmd.get_board_2d_view(
|
||||||
patch("cairosvg.svg2png", return_value=_FAKE_PNG, create=True),
|
{"pcbPath": str(board_path), "format": "png", "responseMode": "file"}
|
||||||
):
|
)
|
||||||
result = cmd.get_board_2d_view({"format": "png", "responseMode": "file"})
|
|
||||||
|
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
assert result["format"] == "png"
|
assert result["format"] == "png"
|
||||||
|
|||||||
Reference in New Issue
Block a user