Files
kicad-mcp-server/python/commands/board/view.py
Gavin Colonese fa6cdcc0cd feat: add mil unit support across position/coordinate commands (#162)
* feat(units): add mil unit support across all position/coordinate commands

KiCad natively supports mils, so the MCP server should too. Added "mil"
as a valid unit option in tool schemas and updated all unit-to-nanometer
scale conversions across component, routing, outline, view, and IPC
handler code paths. 1 mil = 25400 nm (0.0254 mm).

Also fixes a pre-existing mypy overload error in pin_locator.py (str cast
on dict.get key) that was blocking pre-commit on any Python file change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(units): add mil to TypeScript tool schemas

The Python-side mil support was added but the actual input validation
happens in the TypeScript/Zod schemas. Updated all z.enum(["mm", "inch"])
to include "mil" across board, component, routing, design-rules, and
export tool definitions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(tools): replace CP-1252 mojibake with correct Unicode in board.ts

Replace U+00C3 U+00D7 (×) with U+00D7 (×) in add_logo size output string.
Character was mangled when file was saved as CP-1252 instead of UTF-8.

* fix: restore em-dash and fix pre-commit mypy in component/routing

component.py: replace CP-1252 mojibake (â€") with correct Unicode
em-dash (—) in the 'Add to board first' comment. Addresses
maintainer review on PR #162.

routing.py: annotate ex/ey as float at first assignment site in
_point_to_segment_distance_nm so mypy pre-commit hook passes
cleanly on this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 17:29:34 -04:00

247 lines
9.2 KiB
Python

"""
Board view command implementations for KiCAD interface
"""
import base64
import io
import logging
import os
from typing import Any, Dict, List, Optional, Tuple
import pcbnew
from PIL import Image
logger = logging.getLogger("kicad_interface")
class BoardViewCommands:
"""Handles board viewing operations"""
def __init__(self, board: Optional[pcbnew.BOARD] = None):
"""Initialize with optional board instance"""
self.board = board
def get_board_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get information about the current board"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
# Get board dimensions
board_box = self.board.GetBoardEdgesBoundingBox()
width_nm = board_box.GetWidth()
height_nm = board_box.GetHeight()
# Convert to mm
width_mm = width_nm / 1000000
height_mm = height_nm / 1000000
# Get layer information
layers = []
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
if self.board.IsLayerEnabled(layer_id):
layers.append(
{
"name": self.board.GetLayerName(layer_id),
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
"id": layer_id,
}
)
return {
"success": True,
"board": {
"filename": self.board.GetFileName(),
"size": {"width": width_mm, "height": height_mm, "unit": "mm"},
"layers": layers,
"title": self.board.GetTitleBlock().GetTitle(),
# Note: activeLayer removed - GetActiveLayer() doesn't exist in KiCAD 9.0
# Active layer is a UI concept not applicable to headless scripting
},
}
except Exception as e:
logger.error(f"Error getting board info: {str(e)}")
return {
"success": False,
"message": "Failed to get board information",
"errorDetails": str(e),
}
def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""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 {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
# Get parameters
width = params.get("width", 800)
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)
# Set up plot options
plot_opts = plotter.GetPlotOptions()
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:
for layer_name in layers:
layer_id = self.board.GetLayerID(layer_name)
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)
temp_svg = plotter.GetPlotFileName()
plotter.ClosePlot()
# 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, "rb") as f:
image_bytes = f.read()
os.remove(temp_svg)
mime_format = "svg"
else:
from cairosvg import svg2png
image_bytes = svg2png(url=temp_svg, output_width=width, output_height=height)
os.remove(temp_svg)
if format == "jpg":
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)}")
return {
"success": False,
"message": "Failed to get board 2D view",
"errorDetails": str(e),
}
def _get_layer_type_name(self, type_id: int) -> str:
"""Convert KiCAD layer type constant to name"""
type_map = {
pcbnew.LT_SIGNAL: "signal",
pcbnew.LT_POWER: "power",
pcbnew.LT_MIXED: "mixed",
pcbnew.LT_JUMPER: "jumper",
}
# Note: LT_USER was removed in KiCAD 9.0
return type_map.get(type_id, "unknown")
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get the bounding box extents of the board"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
# Get unit preference (default to mm)
unit = params.get("unit", "mm")
scale = (
1000000 if unit == "mm" else (25400 if unit == "mil" else 25400000)
) # mm, mil, or inch to nm
# Get board bounding box
board_box = self.board.GetBoardEdgesBoundingBox()
# Extract bounds in nanometers, then convert
left = board_box.GetLeft() / scale
top = board_box.GetTop() / scale
right = board_box.GetRight() / scale
bottom = board_box.GetBottom() / scale
width = board_box.GetWidth() / scale
height = board_box.GetHeight() / scale
# Get center point
center_x = board_box.GetCenter().x / scale
center_y = board_box.GetCenter().y / scale
return {
"success": True,
"extents": {
"left": left,
"top": top,
"right": right,
"bottom": bottom,
"width": width,
"height": height,
"center": {"x": center_x, "y": center_y},
"unit": unit,
},
}
except Exception as e:
logger.error(f"Error getting board extents: {str(e)}")
return {
"success": False,
"message": "Failed to get board extents",
"errorDetails": str(e),
}