* fix: lots of fixes and name refactoring * feat: Add workbench preferences * fix: MCP bridge status widget and just command fixes * fix(tests): Use the correct mesa-glx package * fix(ci): Add fontconfig to GUI test dependencies FreeCAD GUI was failing to start with: "Fontconfig error: Cannot load default config file: No such file" Added fontconfig and fonts-dejavu-core packages to the GUI test job dependencies to resolve the font configuration issue. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(addon): Extract path utilities into shared module Create path_utils.py module that consolidates duplicated path-finding logic from commands.py and InitGui.py: - get_addon_path(): Find addon directory with caching and fallbacks - get_icon_path(): Get full path to an icon file - get_icons_dir(): Get path to icons directory - get_workbench_icon(): Get path to workbench main icon This removes ~100 lines of duplicated code while preserving the same behavior including _addon_path_cache and all fallback methods. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(addon): Prevent stale plugin state on startup failure The StartMCPBridgeCommand.Activated method could leave _mcp_plugin in a partially initialized state if FreecadMCPPlugin.start() failed after the plugin was instantiated. Changes: - Create plugin in a local variable first - Only assign to _mcp_plugin after start() succeeds - Explicitly clear _mcp_plugin and _running_config in exception handlers to ensure clean state for subsequent retry attempts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Lot of broad improvements * fix(ci): Use blocking headless_server.py for GUI tests The GUI test was using startup_bridge.py which is non-blocking (designed for interactive use). For CI, even in GUI mode, we need the blocking headless_server.py that calls run_forever() to keep FreeCAD running. GUI features are still available since we use the 'freecad' executable instead of 'freecadcmd'. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(addon): Rename headless_server.py to blocking_bridge.py The old name was misleading because: - It works with both GUI (freecad) and headless (freecadcmd) modes - The key characteristic is that it BLOCKS with run_forever() New naming convention clarifies the difference: - blocking_bridge.py: Starts bridge and blocks (for CI, servers) - startup_bridge.py: Starts bridge and returns (for interactive GUI) Updated all references across: - GitHub workflow (macro-test.yaml) - Just commands (freecad.just) - Unit tests (test_addon_structure.py) - Documentation (5 files) - CLAUDE.md Also improved the script to detect GUI mode dynamically using FreeCAD.GuiUp and display the appropriate status message. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(just): Remove erroneous rm of startup_bridge.py on error The startup script is now a permanent source file in the repository, not a generated temporary file. The rm -f would have deleted source code if FreeCAD wasn't found. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: General improvements * fix: Lots of general fixes and only stable to PyPi * fix: small cleanup * fix: Small fixes and hopefully fixes the GUI tests * fix: Add proper library paths for FreeCAD GUI in CI - Create wrapper scripts instead of symlinks for AppImage binaries - Set LD_LIBRARY_PATH, QT_PLUGIN_PATH for GUI mode - Add diagnostic output to identify startup failures * fix: Use apprun for GUI tests in CI * fix: Improving Xvfb tests * fix: GUI tests worlk * chore: remove invalid --no-splash comments * fix: ARM64 architecture support and other fixes * fix: cleanup * test: just commands test suite * test: improve just command tests * fix: more general improvements * fix: more cleanup * fix: more updates * fix: small tweaks --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1047 lines
32 KiB
Python
1047 lines
32 KiB
Python
"""XML-RPC bridge for FreeCAD GUI mode communication.
|
|
|
|
This bridge connects to a FreeCAD instance running an XML-RPC server,
|
|
allowing remote control while FreeCAD has its GUI open.
|
|
|
|
Compatible with neka-nat/freecad-mcp XML-RPC protocol, providing
|
|
interoperability with existing FreeCAD MCP addons.
|
|
|
|
Design inspired by neka-nat/freecad-mcp (MIT License):
|
|
- Uses neka-nat's proven XML-RPC protocol (port 9875)
|
|
- Connection health monitoring and auto-reconnect
|
|
- Timeout handling for long operations
|
|
|
|
Attribution:
|
|
The XML-RPC protocol design and port selection (9875) were inspired by
|
|
neka-nat/freecad-mcp (https://github.com/neka-nat/freecad-mcp), which is
|
|
licensed under the MIT License. This implementation is a complete rewrite
|
|
that maintains protocol compatibility.
|
|
"""
|
|
|
|
import asyncio
|
|
import time
|
|
import xmlrpc.client
|
|
from typing import Any
|
|
|
|
from freecad_mcp.bridge.base import (
|
|
ConnectionStatus,
|
|
DocumentInfo,
|
|
ExecutionResult,
|
|
FreecadBridge,
|
|
MacroInfo,
|
|
ObjectInfo,
|
|
ScreenshotResult,
|
|
ViewAngle,
|
|
WorkbenchInfo,
|
|
)
|
|
|
|
DEFAULT_XMLRPC_HOST = "localhost"
|
|
DEFAULT_XMLRPC_PORT = 9875
|
|
DEFAULT_TIMEOUT = 30.0
|
|
|
|
|
|
class XmlRpcBridge(FreecadBridge):
|
|
"""Bridge that communicates with FreeCAD via XML-RPC.
|
|
|
|
This bridge connects to a FreeCAD instance that is running the
|
|
XML-RPC server addon. It provides full access to FreeCAD functionality
|
|
including GUI operations like screenshots.
|
|
|
|
Attributes:
|
|
host: XML-RPC server hostname.
|
|
port: XML-RPC server port.
|
|
timeout: Connection and request timeout in seconds.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
host: str = DEFAULT_XMLRPC_HOST,
|
|
port: int = DEFAULT_XMLRPC_PORT,
|
|
timeout: float = DEFAULT_TIMEOUT,
|
|
) -> None:
|
|
"""Initialize the XML-RPC bridge.
|
|
|
|
Args:
|
|
host: XML-RPC server hostname.
|
|
port: XML-RPC server port.
|
|
timeout: Connection and request timeout in seconds.
|
|
"""
|
|
self._host = host
|
|
self._port = port
|
|
self._timeout = timeout
|
|
self._proxy: xmlrpc.client.ServerProxy | None = None
|
|
self._connected = False
|
|
|
|
@property
|
|
def _server_url(self) -> str:
|
|
"""Get the XML-RPC server URL."""
|
|
return f"http://{self._host}:{self._port}"
|
|
|
|
async def connect(self) -> None:
|
|
"""Establish connection to FreeCAD XML-RPC server.
|
|
|
|
Raises:
|
|
ConnectionError: If connection cannot be established.
|
|
"""
|
|
loop = asyncio.get_event_loop()
|
|
try:
|
|
self._proxy = await loop.run_in_executor(
|
|
None,
|
|
lambda: xmlrpc.client.ServerProxy(
|
|
self._server_url,
|
|
allow_none=True,
|
|
),
|
|
)
|
|
# Test connection with a ping
|
|
await self.ping()
|
|
self._connected = True
|
|
except ConnectionRefusedError as e:
|
|
self._connected = False
|
|
msg = self._get_connection_refused_message()
|
|
raise ConnectionError(msg) from e
|
|
except Exception as e:
|
|
self._connected = False
|
|
error_str = str(e)
|
|
if "Connection refused" in error_str or "Errno 61" in error_str:
|
|
msg = self._get_connection_refused_message()
|
|
else:
|
|
msg = f"Failed to connect to XML-RPC server at {self._server_url}: {e}"
|
|
raise ConnectionError(msg) from e
|
|
|
|
def _get_connection_refused_message(self) -> str:
|
|
"""Get a helpful error message when connection is refused."""
|
|
return f"""
|
|
================================================================================
|
|
CONNECTION REFUSED: Cannot connect to FreeCAD at {self._server_url}
|
|
|
|
The FreeCAD MCP bridge server is not running. To fix this:
|
|
|
|
1. Start FreeCAD (the GUI application)
|
|
|
|
2. Start the MCP bridge using one of these methods:
|
|
|
|
Option A: Using the Robust MCP Bridge Workbench (recommended)
|
|
- Install via FreeCAD Addon Manager: Tools → Addon Manager
|
|
- Search for "Robust MCP Bridge" and install
|
|
- Switch to the Robust MCP Bridge workbench
|
|
- Click "Start MCP Bridge" in the toolbar
|
|
|
|
Option B: From source (for developers)
|
|
- Run: just freecad::run-gui
|
|
|
|
3. You should see: "MCP Bridge started!"
|
|
- XML-RPC: localhost:{self._port}
|
|
- Socket: localhost:9876
|
|
|
|
4. Then restart your MCP client (e.g., restart Claude Code)
|
|
================================================================================
|
|
"""
|
|
|
|
async def disconnect(self) -> None:
|
|
"""Close connection to FreeCAD XML-RPC server."""
|
|
self._proxy = None
|
|
self._connected = False
|
|
|
|
async def is_connected(self) -> bool:
|
|
"""Check if bridge is connected to FreeCAD."""
|
|
if not self._connected or self._proxy is None:
|
|
return False
|
|
|
|
try:
|
|
await self.ping()
|
|
return True
|
|
except Exception:
|
|
self._connected = False
|
|
return False
|
|
|
|
async def ping(self) -> float:
|
|
"""Ping FreeCAD to check connection and measure latency.
|
|
|
|
Returns:
|
|
Round-trip time in milliseconds.
|
|
|
|
Raises:
|
|
ConnectionError: If not connected.
|
|
"""
|
|
if self._proxy is None:
|
|
msg = "Not connected to XML-RPC server"
|
|
raise ConnectionError(msg)
|
|
|
|
loop = asyncio.get_event_loop()
|
|
start = time.perf_counter()
|
|
|
|
try:
|
|
# Try standard system.listMethods or a simple execute
|
|
if self._proxy is None:
|
|
msg = "Not connected"
|
|
raise ConnectionError(msg)
|
|
proxy = self._proxy # Local reference for lambda
|
|
await asyncio.wait_for(
|
|
loop.run_in_executor(
|
|
None,
|
|
lambda: proxy.execute("_result_ = True"),
|
|
),
|
|
timeout=self._timeout,
|
|
)
|
|
except TimeoutError as e:
|
|
msg = "Ping timed out"
|
|
raise ConnectionError(msg) from e
|
|
except Exception as e:
|
|
msg = f"Ping failed: {e}"
|
|
raise ConnectionError(msg) from e
|
|
|
|
return (time.perf_counter() - start) * 1000
|
|
|
|
async def get_status(self) -> ConnectionStatus:
|
|
"""Get detailed connection status.
|
|
|
|
Returns:
|
|
ConnectionStatus with full status information.
|
|
"""
|
|
if not self._connected or self._proxy is None:
|
|
return ConnectionStatus(
|
|
connected=False,
|
|
mode="xmlrpc",
|
|
error="Not connected",
|
|
)
|
|
|
|
try:
|
|
ping_ms = await self.ping()
|
|
version_info = await self.get_freecad_version()
|
|
gui_available = await self.is_gui_available()
|
|
|
|
return ConnectionStatus(
|
|
connected=True,
|
|
mode="xmlrpc",
|
|
freecad_version=version_info.get("version", "unknown"),
|
|
gui_available=gui_available,
|
|
last_ping_ms=ping_ms,
|
|
)
|
|
except Exception as e:
|
|
return ConnectionStatus(
|
|
connected=False,
|
|
mode="xmlrpc",
|
|
error=str(e),
|
|
)
|
|
|
|
# =========================================================================
|
|
# Code Execution
|
|
# =========================================================================
|
|
|
|
async def execute_python(
|
|
self,
|
|
code: str,
|
|
timeout_ms: int = 30000,
|
|
) -> ExecutionResult:
|
|
"""Execute Python code in FreeCAD context via XML-RPC.
|
|
|
|
Args:
|
|
code: Python code to execute.
|
|
timeout_ms: Maximum execution time in milliseconds.
|
|
|
|
Returns:
|
|
ExecutionResult with execution outcome.
|
|
"""
|
|
if self._proxy is None:
|
|
return ExecutionResult(
|
|
success=False,
|
|
result=None,
|
|
stdout="",
|
|
stderr="Not connected to XML-RPC server",
|
|
execution_time_ms=0,
|
|
error_type="ConnectionError",
|
|
)
|
|
|
|
loop = asyncio.get_event_loop()
|
|
start = time.perf_counter()
|
|
proxy = self._proxy # Local reference for lambda
|
|
|
|
try:
|
|
result = await asyncio.wait_for(
|
|
loop.run_in_executor(
|
|
None,
|
|
lambda: proxy.execute(code),
|
|
),
|
|
timeout=timeout_ms / 1000,
|
|
)
|
|
elapsed = (time.perf_counter() - start) * 1000
|
|
|
|
# Parse result from XML-RPC server
|
|
if isinstance(result, dict):
|
|
return ExecutionResult(
|
|
success=result.get("success", False),
|
|
result=result.get("result"),
|
|
stdout=result.get("stdout", ""),
|
|
stderr=result.get("stderr", ""),
|
|
execution_time_ms=elapsed,
|
|
error_type=result.get("error_type"),
|
|
error_traceback=result.get("error_traceback"),
|
|
)
|
|
else:
|
|
# Simple result format
|
|
return ExecutionResult(
|
|
success=True,
|
|
result=result,
|
|
stdout="",
|
|
stderr="",
|
|
execution_time_ms=elapsed,
|
|
)
|
|
|
|
except TimeoutError:
|
|
return ExecutionResult(
|
|
success=False,
|
|
result=None,
|
|
stdout="",
|
|
stderr=f"Execution timed out after {timeout_ms}ms",
|
|
execution_time_ms=float(timeout_ms),
|
|
error_type="TimeoutError",
|
|
)
|
|
except Exception as e:
|
|
elapsed = (time.perf_counter() - start) * 1000
|
|
return ExecutionResult(
|
|
success=False,
|
|
result=None,
|
|
stdout="",
|
|
stderr=str(e),
|
|
execution_time_ms=elapsed,
|
|
error_type=type(e).__name__,
|
|
)
|
|
|
|
# =========================================================================
|
|
# Document Management
|
|
# =========================================================================
|
|
|
|
async def get_documents(self) -> list[DocumentInfo]:
|
|
"""Get list of open documents."""
|
|
result = await self.execute_python(
|
|
"""
|
|
_result_ = []
|
|
for doc in FreeCAD.listDocuments().values():
|
|
_result_.append({
|
|
"name": doc.Name,
|
|
"label": doc.Label,
|
|
"path": doc.FileName or None,
|
|
"objects": [obj.Name for obj in doc.Objects],
|
|
"is_modified": doc.Modified if hasattr(doc, "Modified") else False,
|
|
"active_object": doc.ActiveObject.Name if doc.ActiveObject else None,
|
|
})
|
|
"""
|
|
)
|
|
|
|
if result.success and result.result:
|
|
return [DocumentInfo(**doc) for doc in result.result]
|
|
return []
|
|
|
|
async def get_active_document(self) -> DocumentInfo | None:
|
|
"""Get the active document."""
|
|
result = await self.execute_python(
|
|
"""
|
|
doc = FreeCAD.ActiveDocument
|
|
if doc:
|
|
_result_ = {
|
|
"name": doc.Name,
|
|
"label": doc.Label,
|
|
"path": doc.FileName or None,
|
|
"objects": [obj.Name for obj in doc.Objects],
|
|
"is_modified": doc.Modified if hasattr(doc, "Modified") else False,
|
|
"active_object": doc.ActiveObject.Name if doc.ActiveObject else None,
|
|
}
|
|
else:
|
|
_result_ = None
|
|
"""
|
|
)
|
|
|
|
if result.success and result.result:
|
|
return DocumentInfo(**result.result)
|
|
return None
|
|
|
|
async def create_document(
|
|
self, name: str, label: str | None = None
|
|
) -> DocumentInfo:
|
|
"""Create a new document."""
|
|
label = label or name
|
|
result = await self.execute_python(
|
|
f"""
|
|
doc = FreeCAD.newDocument({name!r})
|
|
doc.Label = {label!r}
|
|
_result_ = {{
|
|
"name": doc.Name,
|
|
"label": doc.Label,
|
|
"path": doc.FileName or None,
|
|
"objects": [],
|
|
"is_modified": False,
|
|
}}
|
|
"""
|
|
)
|
|
|
|
if result.success and result.result:
|
|
return DocumentInfo(**result.result)
|
|
|
|
error_msg = result.error_traceback or "Failed to create document"
|
|
raise ValueError(error_msg)
|
|
|
|
async def open_document(self, path: str) -> DocumentInfo:
|
|
"""Open an existing document."""
|
|
result = await self.execute_python(
|
|
f"""
|
|
import os
|
|
if not os.path.exists({path!r}):
|
|
raise FileNotFoundError(f"File not found: {path!r}")
|
|
|
|
doc = FreeCAD.openDocument({path!r})
|
|
_result_ = {{
|
|
"name": doc.Name,
|
|
"label": doc.Label,
|
|
"path": doc.FileName or None,
|
|
"objects": [obj.Name for obj in doc.Objects],
|
|
"is_modified": doc.Modified if hasattr(doc, "Modified") else False,
|
|
}}
|
|
"""
|
|
)
|
|
|
|
if result.success and result.result:
|
|
return DocumentInfo(**result.result)
|
|
|
|
if "FileNotFoundError" in (result.error_type or ""):
|
|
raise FileNotFoundError(result.stderr)
|
|
|
|
error_msg = result.error_traceback or "Failed to open document"
|
|
raise ValueError(error_msg)
|
|
|
|
async def save_document(
|
|
self,
|
|
doc_name: str | None = None,
|
|
path: str | None = None,
|
|
) -> str:
|
|
"""Save a document."""
|
|
code = f"""
|
|
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
|
if doc is None:
|
|
raise ValueError("No active document" if {doc_name!r} is None else f"Document not found: {doc_name!r}")
|
|
|
|
save_path = {path!r} or doc.FileName
|
|
if not save_path:
|
|
raise ValueError("No path specified for new document")
|
|
|
|
doc.saveAs(save_path)
|
|
_result_ = save_path
|
|
"""
|
|
result = await self.execute_python(code)
|
|
|
|
if result.success and result.result:
|
|
return result.result
|
|
|
|
error_msg = result.error_traceback or "Failed to save document"
|
|
raise ValueError(error_msg)
|
|
|
|
async def close_document(self, doc_name: str | None = None) -> None:
|
|
"""Close a document."""
|
|
code = f"""
|
|
doc_name = {doc_name!r}
|
|
if doc_name is None:
|
|
doc = FreeCAD.ActiveDocument
|
|
if doc:
|
|
doc_name = doc.Name
|
|
else:
|
|
raise ValueError("No active document")
|
|
|
|
FreeCAD.closeDocument(doc_name)
|
|
_result_ = True
|
|
"""
|
|
result = await self.execute_python(code)
|
|
|
|
if not result.success:
|
|
error_msg = result.error_traceback or "Failed to close document"
|
|
raise ValueError(error_msg)
|
|
|
|
# =========================================================================
|
|
# Object Management
|
|
# =========================================================================
|
|
|
|
async def get_objects(self, doc_name: str | None = None) -> list[ObjectInfo]:
|
|
"""Get all objects in a document."""
|
|
code = f"""
|
|
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
|
if doc is None:
|
|
raise ValueError("No document found")
|
|
|
|
objects = []
|
|
for obj in doc.Objects:
|
|
obj_info = {{
|
|
"name": obj.Name,
|
|
"label": obj.Label,
|
|
"type_id": obj.TypeId,
|
|
"visibility": obj.ViewObject.Visibility if hasattr(obj, "ViewObject") and obj.ViewObject else True,
|
|
"children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
|
|
"parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [],
|
|
}}
|
|
objects.append(obj_info)
|
|
|
|
_result_ = objects
|
|
"""
|
|
result = await self.execute_python(code)
|
|
|
|
if result.success and result.result:
|
|
return [ObjectInfo(**obj) for obj in result.result]
|
|
return []
|
|
|
|
async def get_object(
|
|
self,
|
|
obj_name: str,
|
|
doc_name: str | None = None,
|
|
) -> ObjectInfo:
|
|
"""Get detailed object information."""
|
|
code = f"""
|
|
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
|
if doc is None:
|
|
raise ValueError("No document found")
|
|
|
|
obj = doc.getObject({obj_name!r})
|
|
if obj is None:
|
|
raise ValueError(f"Object not found: {obj_name!r}")
|
|
|
|
props = {{}}
|
|
for prop in obj.PropertiesList:
|
|
try:
|
|
val = getattr(obj, prop)
|
|
if hasattr(val, '__class__') and val.__class__.__module__ != 'builtins':
|
|
val = str(val)
|
|
props[prop] = val
|
|
except Exception:
|
|
props[prop] = "<unreadable>"
|
|
|
|
shape_info = None
|
|
if hasattr(obj, "Shape"):
|
|
shape = obj.Shape
|
|
shape_info = {{
|
|
"shape_type": shape.ShapeType,
|
|
"volume": shape.Volume if hasattr(shape, "Volume") else None,
|
|
"area": shape.Area if hasattr(shape, "Area") else None,
|
|
"is_valid": shape.isValid(),
|
|
"is_closed": shape.isClosed() if hasattr(shape, "isClosed") else False,
|
|
"vertex_count": len(shape.Vertexes) if hasattr(shape, "Vertexes") else 0,
|
|
"edge_count": len(shape.Edges) if hasattr(shape, "Edges") else 0,
|
|
"face_count": len(shape.Faces) if hasattr(shape, "Faces") else 0,
|
|
}}
|
|
|
|
_result_ = {{
|
|
"name": obj.Name,
|
|
"label": obj.Label,
|
|
"type_id": obj.TypeId,
|
|
"properties": props,
|
|
"shape_info": shape_info,
|
|
"children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
|
|
"parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [],
|
|
"visibility": obj.ViewObject.Visibility if hasattr(obj, "ViewObject") and obj.ViewObject else True,
|
|
}}
|
|
"""
|
|
result = await self.execute_python(code)
|
|
|
|
if result.success and result.result:
|
|
return ObjectInfo(**result.result)
|
|
|
|
error_msg = result.error_traceback or "Failed to get object"
|
|
raise ValueError(error_msg)
|
|
|
|
async def create_object(
|
|
self,
|
|
type_id: str,
|
|
name: str | None = None,
|
|
properties: dict[str, Any] | None = None,
|
|
doc_name: str | None = None,
|
|
) -> ObjectInfo:
|
|
"""Create a new object."""
|
|
properties = properties or {}
|
|
code = f"""
|
|
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
|
if doc is None:
|
|
raise ValueError("No document found")
|
|
|
|
obj = doc.addObject({type_id!r}, {name!r} or "")
|
|
|
|
# Set properties
|
|
for prop_name, prop_val in {properties!r}.items():
|
|
if hasattr(obj, prop_name):
|
|
setattr(obj, prop_name, prop_val)
|
|
|
|
doc.recompute()
|
|
|
|
_result_ = {{
|
|
"name": obj.Name,
|
|
"label": obj.Label,
|
|
"type_id": obj.TypeId,
|
|
"visibility": True,
|
|
"children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
|
|
"parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [],
|
|
}}
|
|
"""
|
|
result = await self.execute_python(code)
|
|
|
|
if result.success and result.result:
|
|
return ObjectInfo(**result.result)
|
|
|
|
error_msg = result.error_traceback or "Failed to create object"
|
|
raise ValueError(error_msg)
|
|
|
|
async def edit_object(
|
|
self,
|
|
obj_name: str,
|
|
properties: dict[str, Any],
|
|
doc_name: str | None = None,
|
|
) -> ObjectInfo:
|
|
"""Edit object properties."""
|
|
code = f"""
|
|
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
|
if doc is None:
|
|
raise ValueError("No document found")
|
|
|
|
obj = doc.getObject({obj_name!r})
|
|
if obj is None:
|
|
raise ValueError(f"Object not found: {obj_name!r}")
|
|
|
|
# Set properties
|
|
for prop_name, prop_val in {properties!r}.items():
|
|
if hasattr(obj, prop_name):
|
|
setattr(obj, prop_name, prop_val)
|
|
|
|
doc.recompute()
|
|
|
|
_result_ = {{
|
|
"name": obj.Name,
|
|
"label": obj.Label,
|
|
"type_id": obj.TypeId,
|
|
"visibility": obj.ViewObject.Visibility if hasattr(obj, "ViewObject") and obj.ViewObject else True,
|
|
"children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [],
|
|
"parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [],
|
|
}}
|
|
"""
|
|
result = await self.execute_python(code)
|
|
|
|
if result.success and result.result:
|
|
return ObjectInfo(**result.result)
|
|
|
|
error_msg = result.error_traceback or "Failed to edit object"
|
|
raise ValueError(error_msg)
|
|
|
|
async def delete_object(
|
|
self,
|
|
obj_name: str,
|
|
doc_name: str | None = None,
|
|
) -> None:
|
|
"""Delete an object."""
|
|
code = f"""
|
|
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
|
if doc is None:
|
|
raise ValueError("No document found")
|
|
|
|
obj = doc.getObject({obj_name!r})
|
|
if obj is None:
|
|
raise ValueError(f"Object not found: {obj_name!r}")
|
|
|
|
doc.removeObject({obj_name!r})
|
|
_result_ = True
|
|
"""
|
|
result = await self.execute_python(code)
|
|
|
|
if not result.success:
|
|
error_msg = result.error_traceback or "Failed to delete object"
|
|
raise ValueError(error_msg)
|
|
|
|
# =========================================================================
|
|
# View and Screenshot
|
|
# =========================================================================
|
|
|
|
async def get_screenshot(
|
|
self,
|
|
view_angle: ViewAngle | None = None,
|
|
width: int = 800,
|
|
height: int = 600,
|
|
doc_name: str | None = None,
|
|
) -> ScreenshotResult:
|
|
"""Capture a screenshot of the 3D view via XML-RPC."""
|
|
view_angle_str = view_angle.value if view_angle else "Isometric"
|
|
code = f"""
|
|
import base64
|
|
import tempfile
|
|
import os
|
|
|
|
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
|
if doc is None:
|
|
raise ValueError("No document found")
|
|
|
|
# Check if GUI is available
|
|
if not FreeCAD.GuiUp:
|
|
raise RuntimeError("GUI not available")
|
|
|
|
view = FreeCADGui.ActiveDocument.ActiveView
|
|
if view is None:
|
|
raise ValueError("No active view")
|
|
|
|
# Check if this is a 3D view (not TechDraw, Spreadsheet, etc.)
|
|
view_type = view.__class__.__name__
|
|
if view_type not in ["View3DInventor", "View3DInventorPy"]:
|
|
raise ValueError(f"Cannot capture screenshot from {{view_type}} view")
|
|
|
|
# Set view angle
|
|
view_type_str = {view_angle_str!r}
|
|
if view_type_str == "FitAll":
|
|
view.fitAll()
|
|
elif view_type_str == "Isometric":
|
|
view.viewIsometric()
|
|
elif view_type_str == "Front":
|
|
view.viewFront()
|
|
elif view_type_str == "Back":
|
|
view.viewRear()
|
|
elif view_type_str == "Top":
|
|
view.viewTop()
|
|
elif view_type_str == "Bottom":
|
|
view.viewBottom()
|
|
elif view_type_str == "Left":
|
|
view.viewLeft()
|
|
elif view_type_str == "Right":
|
|
view.viewRight()
|
|
|
|
# Save to temp file and read
|
|
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
|
|
temp_path = f.name
|
|
|
|
view.saveImage(temp_path, {width}, {height}, "Current")
|
|
|
|
with open(temp_path, "rb") as f:
|
|
image_data = base64.b64encode(f.read()).decode("utf-8")
|
|
|
|
os.unlink(temp_path)
|
|
|
|
_result_ = {{
|
|
"success": True,
|
|
"data": image_data,
|
|
"format": "png",
|
|
"width": {width},
|
|
"height": {height},
|
|
}}
|
|
"""
|
|
result = await self.execute_python(code)
|
|
|
|
if result.success and result.result:
|
|
return ScreenshotResult(
|
|
success=True,
|
|
data=result.result["data"],
|
|
format=result.result["format"],
|
|
width=result.result["width"],
|
|
height=result.result["height"],
|
|
view_angle=view_angle,
|
|
)
|
|
|
|
return ScreenshotResult(
|
|
success=False,
|
|
error=result.error_traceback
|
|
or result.stderr
|
|
or "Failed to capture screenshot",
|
|
width=width,
|
|
height=height,
|
|
view_angle=view_angle,
|
|
)
|
|
|
|
async def set_view(
|
|
self,
|
|
view_angle: ViewAngle,
|
|
doc_name: str | None = None,
|
|
) -> None:
|
|
"""Set the 3D view angle."""
|
|
view_angle_str = view_angle.value
|
|
code = f"""
|
|
doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r})
|
|
if doc is None:
|
|
raise ValueError("No document found")
|
|
|
|
if not FreeCAD.GuiUp:
|
|
_result_ = True # Silently succeed in headless mode
|
|
else:
|
|
view = FreeCADGui.ActiveDocument.ActiveView
|
|
if view is None:
|
|
raise ValueError("No active view")
|
|
|
|
view_type = {view_angle_str!r}
|
|
if view_type == "FitAll":
|
|
view.fitAll()
|
|
elif view_type == "Isometric":
|
|
view.viewIsometric()
|
|
elif view_type == "Front":
|
|
view.viewFront()
|
|
elif view_type == "Back":
|
|
view.viewRear()
|
|
elif view_type == "Top":
|
|
view.viewTop()
|
|
elif view_type == "Bottom":
|
|
view.viewBottom()
|
|
elif view_type == "Left":
|
|
view.viewLeft()
|
|
elif view_type == "Right":
|
|
view.viewRight()
|
|
|
|
_result_ = True
|
|
"""
|
|
await self.execute_python(code)
|
|
|
|
# =========================================================================
|
|
# Macros
|
|
# =========================================================================
|
|
|
|
async def get_macros(self) -> list[MacroInfo]:
|
|
"""Get list of available macros."""
|
|
code = """
|
|
import os
|
|
|
|
# Get macro paths
|
|
macro_paths = []
|
|
|
|
# User macro path
|
|
user_path = FreeCAD.getUserMacroDir(True)
|
|
if os.path.exists(user_path):
|
|
macro_paths.append(("user", user_path))
|
|
|
|
# System macro path
|
|
system_path = FreeCAD.getResourceDir() + "Macro"
|
|
if os.path.exists(system_path):
|
|
macro_paths.append(("system", system_path))
|
|
|
|
macros = []
|
|
for source, path in macro_paths:
|
|
for filename in os.listdir(path):
|
|
if filename.endswith(".FCMacro"):
|
|
macro_file = os.path.join(path, filename)
|
|
description = ""
|
|
try:
|
|
with open(macro_file, "r") as f:
|
|
for line in f:
|
|
if line.startswith("#"):
|
|
desc = line.lstrip("#").strip()
|
|
if desc and not desc.startswith("!") and not desc.startswith("-*-"):
|
|
description = desc
|
|
break
|
|
except Exception:
|
|
pass
|
|
|
|
macros.append({
|
|
"name": filename[:-8], # Remove .FCMacro
|
|
"path": macro_file,
|
|
"description": description,
|
|
"is_system": source == "system",
|
|
})
|
|
|
|
_result_ = macros
|
|
"""
|
|
result = await self.execute_python(code)
|
|
|
|
if result.success and result.result:
|
|
return [MacroInfo(**m) for m in result.result]
|
|
return []
|
|
|
|
async def run_macro(
|
|
self,
|
|
macro_name: str,
|
|
args: dict[str, Any] | None = None,
|
|
) -> ExecutionResult:
|
|
"""Run a macro by name."""
|
|
args = args or {}
|
|
code = f"""
|
|
import os
|
|
|
|
# Find macro file
|
|
macro_name = {macro_name!r}
|
|
macro_file = None
|
|
|
|
# Check user macros first
|
|
user_path = FreeCAD.getUserMacroDir(True)
|
|
user_macro = os.path.join(user_path, macro_name + ".FCMacro")
|
|
if os.path.exists(user_macro):
|
|
macro_file = user_macro
|
|
|
|
# Check system macros
|
|
if not macro_file:
|
|
system_path = FreeCAD.getResourceDir() + "Macro"
|
|
system_macro = os.path.join(system_path, macro_name + ".FCMacro")
|
|
if os.path.exists(system_macro):
|
|
macro_file = system_macro
|
|
|
|
if not macro_file:
|
|
raise FileNotFoundError(f"Macro not found: {{macro_name}}")
|
|
|
|
# Set up arguments
|
|
args = {args!r}
|
|
for k, v in args.items():
|
|
exec(f"{{k}} = {{v!r}}")
|
|
|
|
# Execute macro
|
|
with open(macro_file, "r") as f:
|
|
macro_code = f.read()
|
|
|
|
exec(macro_code)
|
|
_result_ = True
|
|
"""
|
|
return await self.execute_python(code)
|
|
|
|
async def create_macro(
|
|
self,
|
|
name: str,
|
|
code: str,
|
|
description: str = "",
|
|
) -> MacroInfo:
|
|
"""Create a new macro."""
|
|
create_code = f"""
|
|
import os
|
|
|
|
macro_path = FreeCAD.getUserMacroDir(True)
|
|
os.makedirs(macro_path, exist_ok=True)
|
|
|
|
macro_file = os.path.join(macro_path, {name!r} + ".FCMacro")
|
|
|
|
header = ""
|
|
if {description!r}:
|
|
header = f"# {description!r}\\n\\n"
|
|
|
|
full_code = header + '''# -*- coding: utf-8 -*-
|
|
# FreeCAD Macro: {name}
|
|
# Created via MCP Bridge
|
|
|
|
import FreeCAD
|
|
import FreeCADGui
|
|
|
|
''' + {code!r}
|
|
|
|
with open(macro_file, "w") as f:
|
|
f.write(full_code)
|
|
|
|
_result_ = {{
|
|
"name": {name!r},
|
|
"path": macro_file,
|
|
"description": {description!r},
|
|
"is_system": False,
|
|
}}
|
|
"""
|
|
result = await self.execute_python(create_code)
|
|
|
|
if result.success and result.result:
|
|
return MacroInfo(**result.result)
|
|
|
|
error_msg = result.error_traceback or "Failed to create macro"
|
|
raise ValueError(error_msg)
|
|
|
|
# =========================================================================
|
|
# Workbenches
|
|
# =========================================================================
|
|
|
|
async def get_workbenches(self) -> list[WorkbenchInfo]:
|
|
"""Get list of available workbenches."""
|
|
code = """
|
|
workbenches = []
|
|
active_wb = FreeCADGui.activeWorkbench() if FreeCAD.GuiUp else None
|
|
active_name = active_wb.__class__.__name__ if active_wb else None
|
|
|
|
if FreeCAD.GuiUp:
|
|
for name in FreeCADGui.listWorkbenches():
|
|
wb = FreeCADGui.getWorkbench(name)
|
|
workbenches.append({
|
|
"name": name,
|
|
"label": wb.MenuText if hasattr(wb, "MenuText") else name,
|
|
"icon": wb.Icon if hasattr(wb, "Icon") else "",
|
|
"is_active": name == active_name,
|
|
})
|
|
else:
|
|
# Return common workbenches for headless mode
|
|
common = ["StartWorkbench", "PartWorkbench", "PartDesignWorkbench",
|
|
"DraftWorkbench", "SketcherWorkbench", "MeshWorkbench"]
|
|
for name in common:
|
|
workbenches.append({
|
|
"name": name,
|
|
"label": name.replace("Workbench", ""),
|
|
"icon": "",
|
|
"is_active": False,
|
|
})
|
|
|
|
_result_ = workbenches
|
|
"""
|
|
result = await self.execute_python(code)
|
|
|
|
if result.success and result.result:
|
|
return [WorkbenchInfo(**wb) for wb in result.result]
|
|
return []
|
|
|
|
async def activate_workbench(self, workbench_name: str) -> None:
|
|
"""Activate a workbench."""
|
|
code = f"""
|
|
if FreeCAD.GuiUp:
|
|
try:
|
|
FreeCADGui.activateWorkbench({workbench_name!r})
|
|
_result_ = True
|
|
except Exception as e:
|
|
raise ValueError(f"Failed to activate workbench: {{e}}")
|
|
else:
|
|
_result_ = True # Silently succeed in headless mode
|
|
"""
|
|
result = await self.execute_python(code)
|
|
|
|
if not result.success:
|
|
error_msg = result.error_traceback or "Failed to activate workbench"
|
|
raise ValueError(error_msg)
|
|
|
|
# =========================================================================
|
|
# Version and Environment
|
|
# =========================================================================
|
|
|
|
async def get_freecad_version(self) -> dict[str, Any]:
|
|
"""Get FreeCAD version information."""
|
|
code = """
|
|
import sys
|
|
_result_ = {
|
|
"version": ".".join(str(x) for x in FreeCAD.Version()[:3]),
|
|
"version_tuple": FreeCAD.Version()[:3],
|
|
"build_date": FreeCAD.Version()[3] if len(FreeCAD.Version()) > 3 else "unknown",
|
|
"python_version": sys.version,
|
|
"gui_available": FreeCAD.GuiUp,
|
|
}
|
|
"""
|
|
result = await self.execute_python(code)
|
|
|
|
if result.success and result.result:
|
|
return result.result
|
|
|
|
return {
|
|
"version": "unknown",
|
|
"version_tuple": [],
|
|
"build_date": "unknown",
|
|
"python_version": "",
|
|
"gui_available": False,
|
|
}
|
|
|
|
async def is_gui_available(self) -> bool:
|
|
"""Check if FreeCAD GUI is available."""
|
|
result = await self.execute_python("_result_ = FreeCAD.GuiUp")
|
|
return bool(result.success and result.result)
|
|
|
|
# =========================================================================
|
|
# Console
|
|
# =========================================================================
|
|
|
|
async def get_console_output(self, lines: int = 100) -> list[str]:
|
|
"""Get recent console output."""
|
|
code = f"""
|
|
# Try to get console output from FreeCAD
|
|
output_lines = []
|
|
|
|
# Try FreeCAD.Console if available
|
|
if hasattr(FreeCAD, 'Console'):
|
|
console = FreeCAD.Console
|
|
if hasattr(console, 'GetLog'):
|
|
log = console.GetLog()
|
|
if log:
|
|
output_lines = log.split('\\n')[-{lines}:]
|
|
|
|
_result_ = output_lines
|
|
"""
|
|
result = await self.execute_python(code)
|
|
|
|
if result.success and result.result:
|
|
return result.result
|
|
return []
|