From b69a4eb88b0d3c2b7a66d771a90853b339f29c77 Mon Sep 17 00:00:00 2001 From: Gavin Colonese Date: Mon, 18 May 2026 14:29:00 -0400 Subject: [PATCH] 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) * 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 _2d_view. 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) --- python/commands/board/view.py | 64 ++++--- python/schemas/tool_schemas.py | 32 +++- src/tools/board.ts | 17 +- tests/test_get_board_2d_view_save_to_file.py | 169 +++++++++++++++++++ 4 files changed, 256 insertions(+), 26 deletions(-) create mode 100644 tests/test_get_board_2d_view_save_to_file.py diff --git a/python/commands/board/view.py b/python/commands/board/view.py index 0d5859d..f6285d9 100644 --- a/python/commands/board/view.py +++ b/python/commands/board/view.py @@ -73,7 +73,12 @@ class BoardViewCommands: } 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: if not self.board: return { @@ -87,6 +92,7 @@ class BoardViewCommands: height = params.get("height", 600) format = params.get("format", "png") layers = params.get("layers", []) + response_mode = params.get("responseMode", "inline") # Create plot controller plotter = pcbnew.PLOT_CONTROLLER(self.board) @@ -124,36 +130,48 @@ class BoardViewCommands: 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": - with open(temp_svg, "r") as f: - svg_data = f.read() + with open(temp_svg, "rb") as f: + image_bytes = f.read() os.remove(temp_svg) - return {"success": True, "imageData": svg_data, "format": "svg"} + mime_format = "svg" else: - # Use PIL to convert SVG to PNG/JPG 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) if format == "jpg": - # Convert PNG to JPG - img = Image.open(io.BytesIO(png_data)) - jpg_buffer = io.BytesIO() - img.convert("RGB").save(jpg_buffer, format="JPEG") - jpg_data = jpg_buffer.getvalue() - return { - "success": True, - "imageData": base64.b64encode(jpg_data).decode("utf-8"), - "format": "jpg", - } - else: - return { - "success": True, - "imageData": base64.b64encode(png_data).decode("utf-8"), - "format": "png", - } + img = Image.open(io.BytesIO(image_bytes)) + buf = io.BytesIO() + img.convert("RGB").save(buf, format="JPEG") + image_bytes = buf.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 { + "success": True, + "format": mime_format, + "filePath": output_path, + "message": f"2D view saved to {output_path}", + } + else: + # inline mode: base64-encode and return imageData + image_b64 = base64.b64encode(image_bytes).decode("utf-8") + return { + "success": True, + "format": mime_format, + "imageData": image_b64, + } except Exception as e: logger.error(f"Error getting board 2D view: {str(e)}") diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index 8e1675a..2933ef1 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -228,7 +228,16 @@ BOARD_TOOLS = [ { "name": "get_board_2d_view", "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 ' + "_2d_view. and returns a filePath; callers that can open local files should " + "prefer this mode for large boards." + ), "inputSchema": { "type": "object", "properties": { @@ -244,6 +253,27 @@ BOARD_TOOLS = [ "minimum": 100, "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 _2d_view. next to the PCB and return filePath.' + ), + }, }, }, }, diff --git a/src/tools/board.ts b/src/tools/board.ts index 422e2f2..0525cf7 100644 --- a/src/tools/board.ts +++ b/src/tools/board.ts @@ -362,20 +362,33 @@ export function registerBoardTools(server: McpServer, callKicadScript: CommandFu // ------------------------------------------------------ server.tool( "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 _2d_view.; 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"), width: z.number().optional().describe("Optional width 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"), + 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"); const result = await callKicadScript("get_board_2d_view", { layers, width, height, format, + responseMode, }); return { diff --git a/tests/test_get_board_2d_view_save_to_file.py b/tests/test_get_board_2d_view_save_to_file.py new file mode 100644 index 0000000..424f03f --- /dev/null +++ b/tests/test_get_board_2d_view_save_to_file.py @@ -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 + ``_2d_view.`` 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"" +_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