feat(view): save 2D board view to file instead of base64 (#161)
* Feat: save 2D board view to file instead of returning base64 The 2D view was returning base64-encoded image data in JSON, which often exceeded token/message size limits. Now saves the rendered image (PNG/JPG/SVG) next to the PCB file and returns the file path. This makes the output usable by tools that can read image files directly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(board): add opt-in responseMode param to get_board_2d_view Add a responseMode string parameter (enum: inline | file, default inline) so callers can choose how the rendered image is delivered. - inline (default, pre-PR behavior): image bytes are base64-encoded and returned in the imageData response field -- backward-compatible. - file: image is written next to the .kicad_pcb as <board>_2d_view.<ext> and filePath is returned -- resolves the MCP message-size limit problem on large boards. Rendering logic is shared between both modes; only response packaging differs. Updated tool schema (Python + TypeScript) and replaced the existing test file with 5 focused unit tests covering inline/file modes for PNG and SVG formats plus the default-is-inline contract. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -73,7 +73,12 @@ 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"""
|
"""Get a 2D image of the PCB.
|
||||||
|
|
||||||
|
responseMode controls how the image is returned:
|
||||||
|
- "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.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
if not self.board:
|
if not self.board:
|
||||||
return {
|
return {
|
||||||
@@ -87,6 +92,7 @@ class BoardViewCommands:
|
|||||||
height = params.get("height", 600)
|
height = params.get("height", 600)
|
||||||
format = params.get("format", "png")
|
format = params.get("format", "png")
|
||||||
layers = params.get("layers", [])
|
layers = params.get("layers", [])
|
||||||
|
response_mode = params.get("responseMode", "inline")
|
||||||
|
|
||||||
# Create plot controller
|
# Create plot controller
|
||||||
plotter = pcbnew.PLOT_CONTROLLER(self.board)
|
plotter = pcbnew.PLOT_CONTROLLER(self.board)
|
||||||
@@ -124,35 +130,47 @@ class BoardViewCommands:
|
|||||||
|
|
||||||
plotter.ClosePlot()
|
plotter.ClosePlot()
|
||||||
|
|
||||||
# Convert SVG to requested format
|
# Determine output path next to the PCB file
|
||||||
|
board_dir = os.path.dirname(self.board.GetFileName())
|
||||||
|
board_name = os.path.splitext(os.path.basename(self.board.GetFileName()))[0]
|
||||||
|
|
||||||
|
# --- Render to bytes (shared for both response modes) ---
|
||||||
if format == "svg":
|
if format == "svg":
|
||||||
with open(temp_svg, "r") as f:
|
with open(temp_svg, "rb") as f:
|
||||||
svg_data = f.read()
|
image_bytes = f.read()
|
||||||
os.remove(temp_svg)
|
os.remove(temp_svg)
|
||||||
return {"success": True, "imageData": svg_data, "format": "svg"}
|
mime_format = "svg"
|
||||||
else:
|
else:
|
||||||
# Use PIL to convert SVG to PNG/JPG
|
|
||||||
from cairosvg import svg2png
|
from cairosvg import svg2png
|
||||||
|
|
||||||
png_data = svg2png(url=temp_svg, output_width=width, output_height=height)
|
image_bytes = svg2png(url=temp_svg, output_width=width, output_height=height)
|
||||||
os.remove(temp_svg)
|
os.remove(temp_svg)
|
||||||
|
|
||||||
if format == "jpg":
|
if format == "jpg":
|
||||||
# Convert PNG to JPG
|
img = Image.open(io.BytesIO(image_bytes))
|
||||||
img = Image.open(io.BytesIO(png_data))
|
buf = io.BytesIO()
|
||||||
jpg_buffer = io.BytesIO()
|
img.convert("RGB").save(buf, format="JPEG")
|
||||||
img.convert("RGB").save(jpg_buffer, format="JPEG")
|
image_bytes = buf.getvalue()
|
||||||
jpg_data = jpg_buffer.getvalue()
|
mime_format = format
|
||||||
|
|
||||||
|
# --- Package response according to responseMode ---
|
||||||
|
if response_mode == "file":
|
||||||
|
output_path = os.path.join(board_dir, f"{board_name}_2d_view.{mime_format}")
|
||||||
|
with open(output_path, "wb") as f:
|
||||||
|
f.write(image_bytes)
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"imageData": base64.b64encode(jpg_data).decode("utf-8"),
|
"format": mime_format,
|
||||||
"format": "jpg",
|
"filePath": 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,
|
||||||
"imageData": base64.b64encode(png_data).decode("utf-8"),
|
"format": mime_format,
|
||||||
"format": "png",
|
"imageData": image_b64,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -228,7 +228,16 @@ BOARD_TOOLS = [
|
|||||||
{
|
{
|
||||||
"name": "get_board_2d_view",
|
"name": "get_board_2d_view",
|
||||||
"title": "Render Board Preview",
|
"title": "Render Board Preview",
|
||||||
"description": "Generates a 2D visual representation of the current board state as a PNG image.",
|
"description": (
|
||||||
|
"Generates a 2D visual representation of the current board state as a PNG, JPG, or SVG image. "
|
||||||
|
"Use responseMode to control how the image is returned. "
|
||||||
|
'responseMode="inline" (default) returns the image bytes as a base64-encoded imageData '
|
||||||
|
"string in the JSON response — convenient for small boards but may exceed message-size limits on "
|
||||||
|
"large designs. "
|
||||||
|
'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 "
|
||||||
|
"prefer this mode for large boards."
|
||||||
|
),
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -244,6 +253,27 @@ BOARD_TOOLS = [
|
|||||||
"minimum": 100,
|
"minimum": 100,
|
||||||
"default": 600,
|
"default": 600,
|
||||||
},
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["png", "jpg", "svg"],
|
||||||
|
"description": "Output image format (default: png)",
|
||||||
|
"default": "png",
|
||||||
|
},
|
||||||
|
"layers": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Optional list of layer names to include; all enabled layers if omitted",
|
||||||
|
},
|
||||||
|
"responseMode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["inline", "file"],
|
||||||
|
"default": "inline",
|
||||||
|
"description": (
|
||||||
|
"How to return the image. "
|
||||||
|
'"inline" (default): base64-encoded bytes in the imageData response field. '
|
||||||
|
'"file": write to <board>_2d_view.<ext> next to the PCB and return filePath.'
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -362,20 +362,33 @@ 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 current PCB board and return it as PNG, JPG or SVG.",
|
||||||
|
"Use responseMode to choose how the image is delivered:",
|
||||||
|
' "inline" (default) — base64-encoded bytes returned in imageData; works well for small boards.',
|
||||||
|
' "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.",
|
||||||
|
].join(" "),
|
||||||
{
|
{
|
||||||
layers: z.array(z.string()).optional().describe("Optional array of layer names to include"),
|
layers: z.array(z.string()).optional().describe("Optional array of layer names to include"),
|
||||||
width: z.number().optional().describe("Optional width of the image in pixels"),
|
width: z.number().optional().describe("Optional width of the image in pixels"),
|
||||||
height: z.number().optional().describe("Optional height of the image in pixels"),
|
height: z.number().optional().describe("Optional height of the image in pixels"),
|
||||||
format: z.enum(["png", "jpg", "svg"]).optional().describe("Image format"),
|
format: z.enum(["png", "jpg", "svg"]).optional().describe("Image format"),
|
||||||
|
responseMode: z
|
||||||
|
.enum(["inline", "file"])
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'How to return the image: "inline" (default) returns base64 imageData; "file" writes to disk and returns filePath',
|
||||||
|
),
|
||||||
},
|
},
|
||||||
async ({ layers, width, height, format }) => {
|
async ({ 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", {
|
||||||
layers,
|
layers,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
format,
|
format,
|
||||||
|
responseMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
169
tests/test_get_board_2d_view_save_to_file.py
Normal file
169
tests/test_get_board_2d_view_save_to_file.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""Tests for ``get_board_2d_view`` responseMode parameter.
|
||||||
|
|
||||||
|
Two modes are supported:
|
||||||
|
- ``responseMode="inline"`` (default): image bytes are base64-encoded and returned in the
|
||||||
|
``imageData`` field of the response. No file is written to disk.
|
||||||
|
- ``responseMode="file"``: image is written next to the ``.kicad_pcb`` file as
|
||||||
|
``<board_name>_2d_view.<ext>`` and ``filePath`` is returned. No ``imageData`` field is
|
||||||
|
present.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_view_cmd(tmp_path: Path):
|
||||||
|
"""Build a BoardViewCommands instance with a fake board."""
|
||||||
|
from commands.board.view import BoardViewCommands
|
||||||
|
|
||||||
|
board_path = tmp_path / "MyBoard.kicad_pcb"
|
||||||
|
board_path.write_text("(kicad_pcb)")
|
||||||
|
|
||||||
|
fake_board = MagicMock()
|
||||||
|
fake_board.GetFileName.return_value = str(board_path)
|
||||||
|
return BoardViewCommands(board=fake_board), tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _patched_plotter(temp_svg: Path):
|
||||||
|
"""Context manager that stubs PLOT_CONTROLLER to return the given temp SVG path."""
|
||||||
|
plotter = MagicMock()
|
||||||
|
plotter.GetPlotFileName.return_value = str(temp_svg)
|
||||||
|
plotter.OpenPlotfile.return_value = True
|
||||||
|
plotter.GetPlotOptions.return_value = MagicMock()
|
||||||
|
return patch("commands.board.view.pcbnew.PLOT_CONTROLLER", return_value=plotter)
|
||||||
|
|
||||||
|
|
||||||
|
_FAKE_SVG = b"<svg><rect/></svg>"
|
||||||
|
_FAKE_PNG = b"\x89PNG\r\n\x1a\n" + b"fakepngbytes"
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# inline mode tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_inline_png_returns_base64_image_data(tmp_path):
|
||||||
|
"""responseMode='inline' with format='png' returns valid base64 in imageData."""
|
||||||
|
cmd, root = _make_view_cmd(tmp_path)
|
||||||
|
temp_svg = root / "scratch.svg"
|
||||||
|
temp_svg.write_bytes(_FAKE_SVG)
|
||||||
|
_install_cairosvg_stub(_FAKE_PNG)
|
||||||
|
|
||||||
|
with (
|
||||||
|
_patched_plotter(temp_svg),
|
||||||
|
patch("cairosvg.svg2png", return_value=_FAKE_PNG, create=True),
|
||||||
|
):
|
||||||
|
result = cmd.get_board_2d_view({"format": "png", "responseMode": "inline"})
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["format"] == "png"
|
||||||
|
assert "imageData" in result
|
||||||
|
decoded = base64.b64decode(result["imageData"])
|
||||||
|
assert decoded == _FAKE_PNG
|
||||||
|
# No file should have been written for inline mode
|
||||||
|
assert not (root / "MyBoard_2d_view.png").exists()
|
||||||
|
assert "filePath" not in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_inline_svg_returns_base64_image_data(tmp_path):
|
||||||
|
"""responseMode='inline' with format='svg' returns valid base64 in imageData."""
|
||||||
|
cmd, root = _make_view_cmd(tmp_path)
|
||||||
|
temp_svg = root / "scratch.svg"
|
||||||
|
temp_svg.write_bytes(_FAKE_SVG)
|
||||||
|
|
||||||
|
with _patched_plotter(temp_svg):
|
||||||
|
result = cmd.get_board_2d_view({"format": "svg", "responseMode": "inline"})
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["format"] == "svg"
|
||||||
|
assert "imageData" in result
|
||||||
|
decoded = base64.b64decode(result["imageData"])
|
||||||
|
assert decoded == _FAKE_SVG
|
||||||
|
assert "filePath" not in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_default_response_mode_is_inline(tmp_path):
|
||||||
|
"""Omitting responseMode defaults to inline behavior (imageData, no filePath)."""
|
||||||
|
cmd, root = _make_view_cmd(tmp_path)
|
||||||
|
temp_svg = root / "scratch.svg"
|
||||||
|
temp_svg.write_bytes(_FAKE_SVG)
|
||||||
|
_install_cairosvg_stub(_FAKE_PNG)
|
||||||
|
|
||||||
|
with (
|
||||||
|
_patched_plotter(temp_svg),
|
||||||
|
patch("cairosvg.svg2png", return_value=_FAKE_PNG, create=True),
|
||||||
|
):
|
||||||
|
result = cmd.get_board_2d_view({"format": "png"})
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert "imageData" in result
|
||||||
|
assert "filePath" not in result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# file mode tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_file_mode_svg_writes_file_and_returns_path(tmp_path):
|
||||||
|
"""responseMode='file' with format='svg' writes the file and returns filePath."""
|
||||||
|
cmd, root = _make_view_cmd(tmp_path)
|
||||||
|
temp_svg = root / "scratch.svg"
|
||||||
|
temp_svg.write_bytes(_FAKE_SVG)
|
||||||
|
|
||||||
|
with _patched_plotter(temp_svg):
|
||||||
|
result = cmd.get_board_2d_view({"format": "svg", "responseMode": "file"})
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["format"] == "svg"
|
||||||
|
expected = root / "MyBoard_2d_view.svg"
|
||||||
|
assert Path(result["filePath"]).resolve() == expected.resolve()
|
||||||
|
assert expected.exists()
|
||||||
|
assert expected.read_bytes() == _FAKE_SVG
|
||||||
|
assert "imageData" not in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_file_mode_png_writes_file_and_returns_path(tmp_path):
|
||||||
|
"""responseMode='file' with format='png' writes the PNG and returns filePath."""
|
||||||
|
cmd, root = _make_view_cmd(tmp_path)
|
||||||
|
temp_svg = root / "scratch.svg"
|
||||||
|
temp_svg.write_bytes(_FAKE_SVG)
|
||||||
|
_install_cairosvg_stub(_FAKE_PNG)
|
||||||
|
|
||||||
|
with (
|
||||||
|
_patched_plotter(temp_svg),
|
||||||
|
patch("cairosvg.svg2png", return_value=_FAKE_PNG, create=True),
|
||||||
|
):
|
||||||
|
result = cmd.get_board_2d_view({"format": "png", "responseMode": "file"})
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["format"] == "png"
|
||||||
|
expected = root / "MyBoard_2d_view.png"
|
||||||
|
assert Path(result["filePath"]).resolve() == expected.resolve()
|
||||||
|
assert expected.exists()
|
||||||
|
assert expected.read_bytes() == _FAKE_PNG
|
||||||
|
assert "imageData" not in result
|
||||||
Reference in New Issue
Block a user