pax_global_header 0000666 0000000 0000000 00000000064 15241274622 0014517 g ustar 00root root 0000000 0000000 52 comment=3745ff9a7ddc127831873048e0000ce64e8b9c1f
freecad-mcp-0.1.22/ 0000775 0000000 0000000 00000000000 15241274622 0013747 5 ustar 00root root 0000000 0000000 freecad-mcp-0.1.22/.github/ 0000775 0000000 0000000 00000000000 15241274622 0015307 5 ustar 00root root 0000000 0000000 freecad-mcp-0.1.22/.github/workflows/ 0000775 0000000 0000000 00000000000 15241274622 0017344 5 ustar 00root root 0000000 0000000 freecad-mcp-0.1.22/.github/workflows/test.yml 0000664 0000000 0000000 00000001617 15241274622 0021053 0 ustar 00root root 0000000 0000000 name: test
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
python-version: ${{ matrix.python-version }}
enable-cache: true
- run: uv lock --check
- run: uv run python -m compileall src addon
- run: uv run pytest -q
mcp-sdk-compatibility:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
mcp-requirement:
- "mcp[cli]>=1.12.2,<2"
- "mcp[cli]>=2,<3"
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
python-version: "3.12"
enable-cache: true
- run: uv run --with '${{ matrix.mcp-requirement }}' freecad-mcp --help
freecad-mcp-0.1.22/.gitignore 0000664 0000000 0000000 00000000043 15241274622 0015734 0 ustar 00root root 0000000 0000000 __pycache__/
.venv/
.pytest_cache/
freecad-mcp-0.1.22/.python-version 0000664 0000000 0000000 00000000005 15241274622 0016747 0 ustar 00root root 0000000 0000000 3.12
freecad-mcp-0.1.22/LICENSE 0000664 0000000 0000000 00000002065 15241274622 0014757 0 ustar 00root root 0000000 0000000 MIT License
Copyright (c) 2025 Shirokuma (k tanaka)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
freecad-mcp-0.1.22/README.md 0000664 0000000 0000000 00000016713 15241274622 0015236 0 ustar 00root root 0000000 0000000 [](https://mseep.ai/app/neka-nat-freecad-mcp)
# FreeCAD MCP
This repository is a FreeCAD MCP that allows you to control FreeCAD from Claude Desktop.
## Demo
### Design a flange

### Design a toy car

### Design a part from 2D drawing
#### Input 2D drawing

#### Demo

This is the conversation history.
https://claude.ai/share/7b48fd60-68ba-46fb-bb21-2fbb17399b48
## Install addon
FreeCAD Addon directory is
* Windows: `%APPDATA%\FreeCAD\Mod\`
* Mac:
* FreeCAD 1.1: `~/Library/Application\ Support/FreeCAD/v1-1/Mod/`
* FreeCAD 1.0: `~/Library/Application\ Support/FreeCAD/v1-0/Mod/`
* Linux:
* Ubuntu: `~/.FreeCAD/Mod/` or `~/snap/freecad/common/Mod/` (if you install FreeCAD from snap)
* Debian: `~/.local/share/FreeCAD/Mod`
* Arch / CachyOS (FreeCAD 1.1 from `extra/freecad`): `~/.local/share/FreeCAD/v1-1/Mod/`
* Flatpak: `~/.var/app/org.freecad.FreeCAD/data/FreeCAD/v1-1/Mod/`
Please put `addon/FreeCADMCP` directory to the addon directory.
```bash
git clone https://github.com/neka-nat/freecad-mcp.git
cd freecad-mcp
# For Linux (Ubuntu/Debian)
mkdir -p ~/.FreeCAD/Mod/
cp -r addon/FreeCADMCP ~/.FreeCAD/Mod/
# For Linux (Arch/CachyOS, FreeCAD 1.1 from extra/freecad)
mkdir -p ~/.local/share/FreeCAD/v1-1/Mod/
cp -r addon/FreeCADMCP ~/.local/share/FreeCAD/v1-1/Mod/
# For Linux (Flatpak)
mkdir -p ~/.var/app/org.freecad.FreeCAD/data/FreeCAD/v1-1/Mod/
cp -r addon/FreeCADMCP ~/.var/app/org.freecad.FreeCAD/data/FreeCAD/v1-1/Mod/
# For macOS (FreeCAD 1.1)
mkdir -p ~/Library/Application\ Support/FreeCAD/v1-1/Mod/
cp -r addon/FreeCADMCP ~/Library/Application\ Support/FreeCAD/v1-1/Mod/
```
When you install addon, you need to restart FreeCAD.
You can select "MCP Addon" from Workbench list and use it.

And you can start RPC server by "Start RPC Server" command in "FreeCAD MCP" toolbar.

### Auto-Start RPC Server
By default, the RPC server must be started manually each time FreeCAD opens. To start it automatically:
1. Open the **FreeCAD MCP** menu (switch to the MCP Addon workbench first)
2. Check **Auto-Start Server**
The setting is saved to `freecad_mcp_settings.json` and persists across sessions. On the next FreeCAD launch, the RPC server will start automatically once the application finishes loading.
You can disable it at any time by unchecking **Auto-Start Server** in the same menu.
## Setting up Claude Desktop
Pre-installation of the [uvx](https://docs.astral.sh/uv/guides/tools/) is required.
And you need to edit Claude Desktop config file, `claude_desktop_config.json`.
For user.
```json
{
"mcpServers": {
"freecad": {
"command": "uvx",
"args": [
"freecad-mcp"
]
}
}
}
```
If you want to save token, you can set `only_text_feedback` to `true` and use only text feedback.
```json
{
"mcpServers": {
"freecad": {
"command": "uvx",
"args": [
"freecad-mcp",
"--only-text-feedback"
]
}
}
}
```
Screenshots can also be controlled per tool call instead of globally: every tool that returns a screenshot accepts an optional `include_screenshot` parameter (pass `false` to get text-only feedback, e.g. for analytical scripts or intermediate steps) and an optional `view_name` parameter to orient the screenshot ("Isometric" by default, or "Front", "Top", "Right", etc.). The `--only-text-feedback` flag always wins: when it is set, no screenshots are returned regardless of `include_screenshot`.
For developer.
First, you need clone this repository.
```bash
git clone https://github.com/neka-nat/freecad-mcp.git
```
```json
{
"mcpServers": {
"freecad": {
"command": "uv",
"args": [
"--directory",
"/path/to/freecad-mcp/",
"run",
"freecad-mcp"
]
}
}
}
```
## Remote Connections
By default the RPC server does not accept remote connections and listens on `localhost`. To control FreeCAD from another machine on your network:
### 1. Enable remote connections in FreeCAD
In the **FreeCAD MCP** toolbar:
1. Check **Remote Connections** — the RPC server will bind to `0.0.0.0` (all interfaces) on the next restart. For security reasons, it only accepts connections from the IP addresses or CIDR subnets specified in the **Allowed IPs** field. By default this is `127.0.0.1`.
2. Click **Configure Allowed IPs** and enter a comma-separated list of IP addresses or CIDR subnets that are allowed to connect, e.g.:
```
192.168.1.100, 10.0.0.0/24
```
`127.0.0.1` is always the default. Invalid entries are rejected with an error dialog. Restart the RPC server after changing these settings.
### 2. Point the MCP server at the remote host
Pass the `--host` flag with the IP address or hostname of the machine running FreeCAD:
```json
{
"mcpServers": {
"freecad": {
"command": "uvx",
"args": [
"freecad-mcp",
"--host", "192.168.1.100"
]
}
}
}
```
The `--host` value is validated on startup — it must be a valid IPv4/IPv6 address or hostname.
## Tools
* `create_document`: Create a new document in FreeCAD.
* `create_object`: Create a new object in FreeCAD.
* `edit_object`: Edit an object in FreeCAD.
* `delete_object`: Delete an object in FreeCAD.
* `execute_code`: Execute arbitrary Python code in FreeCAD.
* `insert_part_from_library`: Insert a part from the [parts library](https://github.com/FreeCAD/FreeCAD-library).
* `get_view`: Get a screenshot of the active view.
* `get_objects`: Get all objects in a document.
* `get_object`: Get an object in a document.
* `get_parts_list`: Get the list of parts in the [parts library](https://github.com/FreeCAD/FreeCAD-library).
* `get_rpc_status`: Report RPC and GUI-dispatch health without using the FreeCAD GUI thread.
* `run_fem_analysis`: Run the CalculiX solver on an existing `Fem::FemAnalysis` and return summary results (max von Mises stress, max displacement, node count, working directory). Auto-creates a `SolverCcxTools` if the analysis has none. See [`examples/cantilever_fem.py`](examples/cantilever_fem.py) for an end-to-end usage example.
Tools that return a screenshot (`create_object`, `edit_object`, `delete_object`, `execute_code`, `insert_part_from_library`, `get_objects`, `get_object`, `run_fem_analysis`) accept optional `include_screenshot` (default `true`) and `view_name` (default `"Isometric"`) parameters to suppress or reorient the returned image per call.
### GUI dispatch timeouts
If a GUI-thread operation exceeds its timeout after it has started, the bridge
returns `GUI_DISPATCH_STUCK` and rejects later GUI operations immediately. Use
`get_rpc_status` to identify the operation that is still running. FreeCAD GUI
work cannot be force-cancelled safely; if the status does not return to
`healthy` after the operation finishes, restart FreeCAD.
After an `execute_code` exception on a FreeCAD development build, inspect any
new `FeaturePython` object before mutating or deleting it. In particular, do
not continue with an object whose required `Proxy` was never installed, as
touching that broken object can wedge FreeCAD's GUI thread.
## Contributors
Made with [contrib.rocks](https://contrib.rocks).
freecad-mcp-0.1.22/addon/ 0000775 0000000 0000000 00000000000 15241274622 0015034 5 ustar 00root root 0000000 0000000 freecad-mcp-0.1.22/addon/FreeCADMCP/ 0000775 0000000 0000000 00000000000 15241274622 0016565 5 ustar 00root root 0000000 0000000 freecad-mcp-0.1.22/addon/FreeCADMCP/Init.py 0000664 0000000 0000000 00000000001 15241274622 0020031 0 ustar 00root root 0000000 0000000
freecad-mcp-0.1.22/addon/FreeCADMCP/InitGui.py 0000664 0000000 0000000 00000002776 15241274622 0020523 0 ustar 00root root 0000000 0000000 import sys as _sys
import os as _os
try:
_addon_dir = _os.path.dirname(_os.path.abspath(__file__))
except NameError:
import inspect as _inspect
_addon_dir = _os.path.dirname(_os.path.abspath(_inspect.getfile(_inspect.currentframe())))
if _addon_dir not in _sys.path:
_sys.path.insert(0, _addon_dir)
class FreeCADMCPAddonWorkbench(Workbench):
MenuText = "MCP Addon"
ToolTip = "Addon for MCP Communication"
def Initialize(self):
from rpc_server import rpc_server
commands = [
"Start_RPC_Server",
"Stop_RPC_Server",
"Toggle_Auto_Start",
"Toggle_Remote_Connections",
"Configure_Allowed_IPs",
]
self.appendToolbar("FreeCAD MCP", commands)
self.appendMenu("FreeCAD MCP", commands)
def Activated(self):
pass
def Deactivated(self):
pass
def ContextMenu(self, recipient):
pass
def GetClassName(self):
return "Gui::PythonWorkbench"
Gui.addWorkbench(FreeCADMCPAddonWorkbench())
def _auto_start_mcp():
try:
from rpc_server import rpc_server
settings = rpc_server.load_settings()
if not settings.get("auto_start_rpc", False):
return
msg = rpc_server.start_rpc_server()
FreeCAD.Console.PrintMessage(f"[MCP] Auto-start: {msg}\n")
except Exception as e:
FreeCAD.Console.PrintWarning(f"[MCP] Auto-start failed: {e}\n")
from PySide import QtCore
QtCore.QTimer.singleShot(0, _auto_start_mcp)
freecad-mcp-0.1.22/addon/FreeCADMCP/rpc_server/ 0000775 0000000 0000000 00000000000 15241274622 0020737 5 ustar 00root root 0000000 0000000 freecad-mcp-0.1.22/addon/FreeCADMCP/rpc_server/__init__.py 0000664 0000000 0000000 00000000157 15241274622 0023053 0 ustar 00root root 0000000 0000000 # Relative imports are not safe in FreeCAD addon context.
# InitGui.py imports rpc_server.rpc_server directly.
freecad-mcp-0.1.22/addon/FreeCADMCP/rpc_server/commands.py 0000664 0000000 0000000 00000013372 15241274622 0023120 0 ustar 00root root 0000000 0000000 """Qt Command classes for the MCP Addon workbench menu.
Defines the five toolbar/menu entries (Start, Stop, Toggle Auto-Start,
Toggle Remote, Configure Allowed IPs).
``register_commands()`` and ``schedule_toggle_sync()`` are invoked from
``rpc_server.py`` at import time to preserve current side-effect behavior.
"""
import FreeCAD
import FreeCADGui
from PySide import QtWidgets
from rpc_server.ip_filter import validate_allowed_ips
from rpc_server.settings import load_settings, save_settings
class StartRPCServerCommand:
def GetResources(self):
return {"MenuText": "Start RPC Server", "ToolTip": "Start RPC Server"}
def Activated(self):
from . import rpc_server # late import: avoids circular at module load
msg = rpc_server.start_rpc_server()
FreeCAD.Console.PrintMessage(msg + "\n")
def IsActive(self):
return True
class StopRPCServerCommand:
def GetResources(self):
return {"MenuText": "Stop RPC Server", "ToolTip": "Stop RPC Server"}
def Activated(self):
from . import rpc_server
msg = rpc_server.stop_rpc_server()
FreeCAD.Console.PrintMessage(msg + "\n")
def IsActive(self):
return True
class ToggleRemoteConnectionsCommand:
def GetResources(self):
settings = load_settings()
return {
"MenuText": "Remote Connections",
"ToolTip": "Enable or disable remote connections for the RPC server.",
"Checkable": bool(settings.get("remote_enabled", False)),
}
def Activated(self, checked=0):
from . import rpc_server
settings = load_settings()
settings["remote_enabled"] = bool(checked)
save_settings(settings)
if settings["remote_enabled"]:
allowed_ips = settings.get("allowed_ips", "127.0.0.1")
FreeCAD.Console.PrintMessage(
f"Remote connections enabled. Allowed IPs: {allowed_ips}\n"
)
else:
FreeCAD.Console.PrintMessage("Remote connections disabled.\n")
if rpc_server.rpc_server_instance:
FreeCAD.Console.PrintMessage(
"Restart the RPC server for changes to take effect.\n"
)
def IsActive(self):
return True
class ConfigureAllowedIPsCommand:
def GetResources(self):
return {
"MenuText": "Configure Allowed IPs",
"ToolTip": "Set which IP addresses or subnets are allowed to connect to the RPC server.",
}
def Activated(self):
from . import rpc_server
settings = load_settings()
current_ips = settings.get("allowed_ips", "127.0.0.1")
text, ok = QtWidgets.QInputDialog.getText(
None,
"Allowed IP Addresses",
"Enter allowed IP addresses or subnets (comma-separated):\n"
"Examples: 127.0.0.1, 192.168.1.0/24, 10.0.0.5",
QtWidgets.QLineEdit.Normal,
current_ips,
)
if ok and text.strip():
valid, errors = validate_allowed_ips(text.strip())
if errors:
QtWidgets.QMessageBox.warning(
None,
"Invalid IP Configuration",
"The following errors were found:\n\n"
+ "\n".join(f"• {e}" for e in errors)
+ ("\n\nOnly valid entries will be saved."
if valid else "\n\nNo valid entries found. Settings not changed."),
)
if not valid:
FreeCAD.Console.PrintWarning("Allowed IPs not changed — no valid entries.\n")
return
normalised = ", ".join(valid)
settings["allowed_ips"] = normalised
save_settings(settings)
FreeCAD.Console.PrintMessage(
f"Allowed IPs updated to: {normalised}\n"
)
if rpc_server.rpc_server_instance:
FreeCAD.Console.PrintMessage(
"Restart the RPC server for changes to take effect.\n"
)
else:
FreeCAD.Console.PrintMessage("Allowed IPs not changed.\n")
def IsActive(self):
return True
class ToggleAutoStartCommand:
def GetResources(self):
settings = load_settings()
return {
"MenuText": "Auto-Start Server",
"ToolTip": "Automatically start the RPC server when FreeCAD launches.",
"Checkable": bool(settings.get("auto_start_rpc", False)),
}
def Activated(self, checked=0):
settings = load_settings()
settings["auto_start_rpc"] = bool(checked)
save_settings(settings)
if settings["auto_start_rpc"]:
FreeCAD.Console.PrintMessage(
"MCP RPC server will start automatically on next FreeCAD launch.\n"
)
else:
FreeCAD.Console.PrintMessage(
"MCP RPC server auto-start disabled.\n"
)
def IsActive(self):
return True
def register_commands() -> None:
FreeCADGui.addCommand("Start_RPC_Server", StartRPCServerCommand())
FreeCADGui.addCommand("Stop_RPC_Server", StopRPCServerCommand())
FreeCADGui.addCommand("Toggle_Auto_Start", ToggleAutoStartCommand())
FreeCADGui.addCommand("Toggle_Remote_Connections", ToggleRemoteConnectionsCommand())
FreeCADGui.addCommand("Configure_Allowed_IPs", ConfigureAllowedIPsCommand())
def schedule_toggle_sync() -> None:
"""Compatibility no-op; toggle state is initialized by ``GetResources``.
FreeCAD treats the presence of the ``Checkable`` resource as making an
action checkable and uses its boolean value as the action's initial checked
state. Loading the saved setting in ``GetResources`` therefore avoids any
delayed QAction lookup or workbench activation at startup.
"""
freecad-mcp-0.1.22/addon/FreeCADMCP/rpc_server/dispatch_health.py 0000664 0000000 0000000 00000007332 15241274622 0024442 0 ustar 00root root 0000000 0000000 """Thread-safe health state for the FreeCAD GUI dispatch bridge."""
import threading
import time
from collections.abc import Callable
from typing import Any
class DispatchHealth:
"""Track the currently running GUI task and whether it timed out."""
def __init__(self, clock: Callable[[], float] = time.monotonic):
self._clock = clock
self._lock = threading.Lock()
self._active_task_id = 0
self._operation = ""
self._started_at = 0.0
self._timed_out = False
self._timeout_seconds = 0.0
def start(self, task_id: int, operation: str) -> None:
with self._lock:
self._active_task_id = task_id
self._operation = operation
self._started_at = self._clock()
self._timed_out = False
self._timeout_seconds = 0.0
def finish(self, task_id: int) -> None:
"""Clear state only when the finishing task is still the active one."""
with self._lock:
if self._active_task_id != task_id:
return
self._active_task_id = 0
self._operation = ""
self._started_at = 0.0
self._timed_out = False
self._timeout_seconds = 0.0
def mark_timed_out(self, task_id: int, timeout: float) -> dict[str, Any] | None:
"""Mark a running task as timed out and return the resulting snapshot."""
with self._lock:
if self._active_task_id != task_id:
return None
self._timed_out = True
self._timeout_seconds = float(timeout)
return self._snapshot_locked()
def snapshot(self) -> dict[str, Any]:
with self._lock:
return self._snapshot_locked()
def rejection(self) -> dict[str, Any] | None:
"""Return an immediate failure while a timed-out task is still running."""
snapshot = self.snapshot()
if snapshot["state"] != "stuck":
return None
return stuck_failure(snapshot, just_timed_out=False)
def _snapshot_locked(self) -> dict[str, Any]:
if self._active_task_id == 0:
return {
"state": "healthy",
"task_id": 0,
"operation": "",
"running_for_seconds": 0.0,
"timeout_seconds": 0.0,
}
elapsed = max(0.0, self._clock() - self._started_at)
return {
"state": "stuck" if self._timed_out else "busy",
"task_id": self._active_task_id,
"operation": self._operation,
"running_for_seconds": round(elapsed, 3),
"timeout_seconds": self._timeout_seconds,
}
def stuck_failure(
snapshot: dict[str, Any], *, just_timed_out: bool
) -> dict[str, Any]:
"""Build the structured error returned for an uninterruptible GUI task."""
operation = snapshot.get("operation") or "unknown GUI operation"
running_for = float(snapshot.get("running_for_seconds", 0.0))
timeout = float(snapshot.get("timeout_seconds", 0.0))
if just_timed_out:
lead = (
f"GUI dispatch timed out after {timeout:g}s while '{operation}' was "
"still running on FreeCAD's GUI thread."
)
else:
lead = (
f"GUI dispatch unavailable: '{operation}' timed out after "
f"{timeout:g}s and is still running "
f"({running_for:.1f}s elapsed)."
)
return {
"success": False,
"code": "GUI_DISPATCH_STUCK",
"error": (
lead
+ " The task cannot be safely cancelled. Future GUI operations "
"will fail immediately until it finishes; restart FreeCAD if it "
"remains stuck."
),
"dispatch": snapshot,
}
freecad-mcp-0.1.22/addon/FreeCADMCP/rpc_server/fem_executor.py 0000664 0000000 0000000 00000010076 15241274622 0024002 0 ustar 00root root 0000000 0000000 """CalculiX-driven FEM analysis execution."""
import tempfile
import traceback
import FreeCAD
import ObjectsFem
def run_fem_analysis(doc_name: str, analysis_name: str) -> dict:
"""Run the CalculiX solver on an existing FEM analysis container.
Always returns a dict with at least ``success`` and ``error``/result keys
so the caller can pass it through to the wire response unchanged.
"""
work_dir = None
stage = "initialization"
try:
stage = "document lookup"
try:
doc = FreeCAD.getDocument(doc_name)
except Exception:
return {"success": False, "error": f"Document '{doc_name}' not found."}
analysis = doc.getObject(analysis_name)
if analysis is None:
return {"success": False, "error": f"Analysis '{analysis_name}' not found."}
if analysis.TypeId not in ("Fem::FemAnalysis", "Fem::FemAnalysisPython"):
return {"success": False, "error": f"'{analysis_name}' is not a FEM analysis (TypeId={analysis.TypeId})."}
stage = "solver resolution"
solver = None
for member in analysis.Group:
tid = getattr(member, "TypeId", "")
if "SolverCcx" in tid or "SolverCalculix" in tid:
solver = member
break
if solver is None:
solver_factory = (
getattr(ObjectsFem, "makeSolverCalculiXCcxTools", None)
or getattr(ObjectsFem, "makeSolverCalculixCcxTools", None)
)
if solver_factory is None:
return {"success": False, "error": "ObjectsFem has no Calculix solver factory."}
solver = solver_factory(doc, "CalculiX")
analysis.addObject(solver)
stage = "femtools import"
from femtools import ccxtools
stage = "solver setup"
fea = ccxtools.FemToolsCcx(analysis=analysis, solver=solver)
fea.update_objects()
work_dir = tempfile.mkdtemp(prefix="freecad_mcp_fem_")
fea.setup_working_dir(work_dir)
fea.setup_ccx()
stage = "prerequisite check"
prereq_msg = fea.check_prerequisites()
if prereq_msg:
return {"success": False, "error": f"Prerequisites failed: {prereq_msg}", "working_dir": work_dir}
stage = "solver execution"
fea.purge_results()
# FemToolsCcx.run() reports CalculiX failures by returning False rather
# than raising; None is returned on success in some FreeCAD versions,
# so only an explicit False is treated as a failure.
if fea.run() is False:
return {
"success": False,
"error": "CalculiX solver run failed (fea.run() returned False); inspect the .dat/.frd output in working_dir.",
"working_dir": work_dir,
}
stage = "result loading"
fea.load_results()
result_obj = None
for member in analysis.Group:
if "Result" in getattr(member, "TypeId", "") and hasattr(member, "vonMises"):
result_obj = member
break
if result_obj is None:
return {"success": False, "error": "Solver ran but no result object was produced.", "working_dir": work_dir}
stage = "result extraction"
# vonMises / DisplacementLengths can be None on a degenerate run.
vm = list(getattr(result_obj, "vonMises", None) or [])
disp = list(getattr(result_obj, "DisplacementLengths", None) or [])
doc.recompute()
return {
"success": True,
"result_object": result_obj.Name,
"node_count": len(vm),
"max_von_mises_MPa": max(vm) if vm else None,
"min_von_mises_MPa": min(vm) if vm else None,
"max_displacement_mm": max(disp) if disp else None,
"working_dir": work_dir,
}
except Exception as e:
return {
"success": False,
"error": f"FEM analysis failed during {stage}: {type(e).__name__}: {e}",
"traceback": traceback.format_exc(),
"working_dir": work_dir,
}
freecad-mcp-0.1.22/addon/FreeCADMCP/rpc_server/gui_dispatch.py 0000664 0000000 0000000 00000022135 15241274622 0023757 0 ustar 00root root 0000000 0000000 """GUI-thread task dispatch for the RPC server.
The XML-RPC server runs in its own thread. FreeCAD APIs that touch the GUI
or the document tree must run in the main GUI thread. This module owns the
queue that ferries wrapped callables onto the GUI thread and the helper that
RPC handlers use to invoke them.
Robustness and performance guarantees:
1. Per-call response queues: each ``dispatch_to_gui`` call owns its own
``queue.Queue``. A timeout in one call can never corrupt the response for
a subsequent call.
2. Immediate wake via Qt signal: ``dispatch_to_gui`` emits a signal from the
RPC thread; the GUI thread processes the task immediately rather than
waiting for the next 500 ms heartbeat tick. The 500 ms heartbeat is kept
only as a fallback.
3. Mouse-button guard: ``process_gui_tasks`` skips the current tick while
mouse buttons are held so MCP tasks cannot interrupt 3D navigation drags.
4. Clean shutdown: the ``_SHUTDOWN`` sentinel sets a flag that suppresses the
``finally`` reschedule, so ``stop_rpc_server`` actually stops the loop.
5. Exception isolation: exceptions inside a task are caught, logged, and
returned as error strings; they never kill the dispatch loop.
6. Stuck-task fail-fast: once a task that already started times out, later GUI
calls fail immediately until that task returns. Status remains available
through a GUI-independent RPC method.
"""
import itertools
import queue
import threading
import time
import traceback
from typing import Any, Callable
import FreeCAD
import FreeCADGui
from PySide import QtCore, QtWidgets
from rpc_server.dispatch_health import DispatchHealth, stuck_failure
_rpc_request_queue: "queue.Queue[Any]" = queue.Queue()
_SHUTDOWN = object()
_processing = False # re-entrancy guard: True while process_gui_tasks is draining
_processing_since: float = 0.0 # wall-clock time when _processing became True
_task_ids = itertools.count(1)
_dispatch_health = DispatchHealth()
class _WakeSignal(QtCore.QObject):
"""Qt signal bridge for cross-thread GUI-task wakeup.
Must be created on the GUI thread (``init_waker``). Emitting from the
RPC thread is safe: Qt delivers the connection with ``QueuedConnection``,
so the slot always fires in the GUI thread's event loop.
"""
_sig = QtCore.Signal()
def __init__(self):
super().__init__()
self._sig.connect(self._on_wake, QtCore.Qt.QueuedConnection)
def wake(self) -> None:
self._sig.emit()
def _on_wake(self) -> None:
process_gui_tasks(reschedule=False)
_waker: "_WakeSignal | None" = None
def init_waker() -> None:
"""Create the wake-signal bridge. Call once from the GUI thread."""
global _waker
_waker = _WakeSignal()
def cleanup_waker() -> None:
"""Release the wake-signal bridge on server stop."""
global _waker
_waker = None
def _flush_gui_events(delay_ms: int = 20) -> None:
FreeCADGui.updateGui()
app = QtWidgets.QApplication.instance()
if app is None:
return
# ExcludeUserInputEvents: skip mouse/keyboard events to avoid re-entrancy
# with ongoing navigation. ExcludeSocketNotifiers keeps network I/O out.
flags = (
QtCore.QEventLoop.ExcludeUserInputEvents
| QtCore.QEventLoop.ExcludeSocketNotifiers
)
app.processEvents(flags, delay_ms)
if delay_ms > 0:
QtCore.QThread.msleep(delay_ms)
app.processEvents(flags, delay_ms)
def process_gui_tasks(reschedule: bool = True) -> None:
"""Drain queued GUI-thread callables and optionally reschedule.
Skips the current tick when any mouse button is held (e.g., 3D navigation
drag) or when already executing a task (re-entrancy guard). The guard
prevents ``doc.recompute()`` or ``processEvents()`` inside a task from
triggering a nested ``process_gui_tasks`` call that corrupts FreeCAD state.
``reschedule=False`` is used by the immediate-wake path so it does not
start a second heartbeat chain alongside the existing 500 ms one.
"""
global _processing, _processing_since
if _processing:
return # re-entrant call from processEvents inside a task; skip
shutdown = False
try:
if _rpc_request_queue.empty():
return # nothing queued; skip cursor/status-bar churn on idle heartbeat ticks
if QtWidgets.QApplication.mouseButtons() != QtCore.Qt.NoButton:
return # user is dragging; defer to next tick
if QtWidgets.QApplication.activePopupWidget() is not None:
return # context menu or popup open; defer to next tick
if QtWidgets.QApplication.activeModalWidget() is not None:
return # modal dialog open; defer to next tick
_processing = True
_processing_since = time.monotonic()
app = QtWidgets.QApplication.instance()
try:
status_bar = FreeCADGui.getMainWindow().statusBar()
except Exception:
status_bar = None
if app is not None:
app.setOverrideCursor(QtCore.Qt.WaitCursor)
if status_bar is not None:
status_bar.showMessage("MCP: processing…")
try:
while not _rpc_request_queue.empty():
task = _rpc_request_queue.get()
if task is _SHUTDOWN:
shutdown = True
return
try:
task()
except Exception as e:
FreeCAD.Console.PrintError(
f"MCP RPC: unhandled exception in GUI task: {type(e).__name__}: {e}\n"
f"{traceback.format_exc()}"
)
finally:
if app is not None:
app.restoreOverrideCursor()
if status_bar is not None:
status_bar.clearMessage()
finally:
_processing = False
if not shutdown and reschedule:
QtCore.QTimer.singleShot(500, process_gui_tasks)
def request_shutdown() -> None:
"""Post the sentinel so the next dispatch tick exits without rescheduling."""
_rpc_request_queue.put(_SHUTDOWN)
def get_dispatch_status() -> dict[str, Any]:
"""Return GUI dispatch health without touching FreeCAD's GUI thread."""
return _dispatch_health.snapshot()
def dispatch_to_gui(
task: Callable[[], Any],
timeout: float = 60,
operation_name: str | None = None,
) -> Any:
"""Run ``task`` on the GUI thread and return its result.
Uses a per-call response queue so a timeout in one call never corrupts
the response for a subsequent call. Wakes the GUI thread immediately via
a Qt signal instead of waiting for the next 500 ms heartbeat.
On timeout the queued task is cancelled if it has not started yet. A task
already running on the GUI thread cannot be interrupted; it is marked as
stuck and subsequent GUI calls fail immediately until it returns.
Returns the task's return value on success, an error string if the task
raises, or ``{"success": False, "error": ...}`` on timeout.
"""
rejection = _dispatch_health.rejection()
if rejection is not None:
return rejection
task_id = next(_task_ids)
operation = operation_name or getattr(task, "__name__", "GUI operation")
if operation == "":
operation = "GUI operation"
response_queue: "queue.Queue[Any]" = queue.Queue(maxsize=1)
state_lock = threading.Lock()
started = False
cancelled = False
def _wrapped() -> None:
nonlocal started
with state_lock:
if cancelled:
return # caller timed out and went away; don't run a stale task
started = True
_dispatch_health.start(task_id, operation)
try:
try:
res = task()
except Exception as e:
FreeCAD.Console.PrintError(
f"MCP RPC: GUI task raised {type(e).__name__}: {e}\n"
f"{traceback.format_exc()}"
)
res = f"{type(e).__name__}: {e}"
finally:
_dispatch_health.finish(task_id)
response_queue.put_nowait(res)
_rpc_request_queue.put(_wrapped)
if _waker is not None:
_waker.wake() # immediate wake via Qt signal (thread-safe)
try:
return response_queue.get(timeout=timeout)
except queue.Empty:
with state_lock:
cancelled = True # a queued task must not start after we give up
stuck = (
_dispatch_health.mark_timed_out(task_id, timeout)
if started
else None
)
if stuck is not None:
return stuck_failure(stuck, just_timed_out=True)
# Diagnose why: if _processing is still True, the GUI thread is occupied
# by a long-running task that was queued before this one.
if _processing:
busy_for = time.monotonic() - _processing_since
hint = (
f" (GUI thread has been busy for {busy_for:.1f}s — "
"consider execute_code_async for heavy OCCT operations)"
)
else:
hint = ""
return {"success": False, "error": f"GUI dispatch timed out after {timeout}s{hint}"}
freecad-mcp-0.1.22/addon/FreeCADMCP/rpc_server/ip_filter.py 0000664 0000000 0000000 00000005064 15241274622 0023273 0 ustar 00root root 0000000 0000000 """IP-filtered XML-RPC server and helpers for parsing allowed IP/subnet lists."""
import ipaddress
import re
from xmlrpc.server import SimpleXMLRPCServer
import FreeCAD
class FilteredXMLRPCServer(SimpleXMLRPCServer):
"""XML-RPC server that filters connections by allowed IP addresses/subnets."""
def __init__(self, addr, allowed_ips_str="127.0.0.1", **kwargs):
self._allowed_networks = _parse_allowed_ips(allowed_ips_str)
super().__init__(addr, **kwargs)
def verify_request(self, request, client_address):
client_ip = client_address[0]
try:
addr = ipaddress.ip_address(client_ip)
for network in self._allowed_networks:
if addr in network:
return True
except ValueError:
pass
FreeCAD.Console.PrintWarning(
f"MCP RPC: Rejected connection from {client_ip}\n"
)
return False
_COMMA_SEP_RE = re.compile(r"^\s*[^,\s]+(\s*,\s*[^,\s]+)*\s*$")
def validate_allowed_ips(allowed_ips_str):
"""Validate a comma-separated string of IP addresses/subnets.
Returns a ``(valid, errors)`` tuple. ``valid`` is a list of normalised
entry strings that passed validation; ``errors`` is a list of
human-readable error messages (empty when the input is fully valid).
Checks performed:
1. The overall string is well-formed comma-separated (no leading/trailing
commas, no empty entries between commas, not blank).
2. Each individual entry is a valid IPv4/IPv6 address or CIDR subnet
(validated via the stdlib ``ipaddress`` module).
"""
errors = []
if not allowed_ips_str or not allowed_ips_str.strip():
return [], ["Input must not be empty."]
if not _COMMA_SEP_RE.match(allowed_ips_str):
return [], [
"Malformed list — check for leading/trailing commas, "
"double commas, or missing separators."
]
valid = []
for entry in allowed_ips_str.split(","):
entry = entry.strip()
try:
ipaddress.ip_network(entry, strict=False)
valid.append(entry)
except ValueError:
errors.append(f"Invalid IP/subnet: '{entry}'")
return valid, errors
def _parse_allowed_ips(allowed_ips_str):
"""Parse a comma-separated string of IPs/subnets into a list of ip_network objects."""
valid, errors = validate_allowed_ips(allowed_ips_str)
for msg in errors:
FreeCAD.Console.PrintWarning(f"MCP RPC: {msg}, skipping\n")
return [ipaddress.ip_network(entry, strict=False) for entry in valid]
freecad-mcp-0.1.22/addon/FreeCADMCP/rpc_server/object_factory.py 0000664 0000000 0000000 00000013523 15241274622 0024312 0 ustar 00root root 0000000 0000000 """Object creation dispatch for the RPC ``create_object`` handler.
The legacy ``_create_object_gui`` mixed three flows: FEM mesh (Gmsh) with
legacy parameter remapping, generic FEM-typed objects, and arbitrary
``doc.addObject`` types. Each lives in its own helper here, with a single
public entry point that selects the branch.
"""
import FreeCAD
import ObjectsFem
from rpc_server.property_mapper import Object, set_object_property
from rpc_server.object_validation import object_validity_error
def _create_fem_mesh(doc: FreeCAD.Document, obj: Object):
"""Create a ``Fem::FemMeshGmsh`` and run Gmsh to populate it.
Accepts both the FreeCAD 0.x and 1.x property names (``Part``/``Shape``,
``ElementSize{Max,Min}``/``CharacteristicLength{Max,Min}``).
Returns the created mesh object.
"""
from femmesh.gmshtools import GmshTools
res = getattr(doc, obj.analysis).addObject(
ObjectsFem.makeMeshGmsh(doc, obj.name)
)[0]
geom_attr = "Shape" if hasattr(res, "Shape") else ("Part" if hasattr(res, "Part") else None)
legacy_to_new = {
"Part": geom_attr,
"ElementSizeMax": "CharacteristicLengthMax",
"ElementSizeMin": "CharacteristicLengthMin",
}
geom_key = "Part" if "Part" in obj.properties else ("Shape" if "Shape" in obj.properties else None)
if geom_key is None:
raise ValueError("'Part' (or 'Shape') property not found in properties.")
target_obj = doc.getObject(obj.properties[geom_key])
if target_obj is None:
raise ValueError(f"Referenced object '{obj.properties[geom_key]}' not found.")
if geom_attr is None:
raise ValueError("Mesh object has neither 'Shape' nor 'Part' property.")
setattr(res, geom_attr, target_obj)
del obj.properties[geom_key]
for param, value in obj.properties.items():
target_param = legacy_to_new.get(param, param)
if target_param and hasattr(res, target_param):
setattr(res, target_param, value)
doc.recompute()
GmshTools(res).create_mesh()
FreeCAD.Console.PrintMessage(
f"FEM Mesh '{res.Name}' generated successfully in '{doc.Name}'.\n"
)
return res
def _create_fem_object(doc: FreeCAD.Document, obj: Object):
"""Create a ``Fem::*`` object via the appropriate ``ObjectsFem.makeXxx`` factory."""
fem_make_methods = {
"MaterialCommon": ObjectsFem.makeMaterialSolid,
"AnalysisPython": ObjectsFem.makeAnalysis,
}
obj_type_short = obj.type.split("::")[1]
method_name = "make" + obj_type_short
make_method = fem_make_methods.get(obj_type_short, getattr(ObjectsFem, method_name, None))
if not callable(make_method):
raise ValueError(f"No creation method '{method_name}' found in ObjectsFem.")
res = make_method(doc, obj.name)
set_object_property(doc, res, obj.properties)
FreeCAD.Console.PrintMessage(
f"FEM object '{res.Name}' created with '{method_name}'.\n"
)
if obj.type != "Fem::AnalysisPython" and obj.analysis:
getattr(doc, obj.analysis).addObject(res)
return res
def _create_generic_object(doc: FreeCAD.Document, obj: Object):
res = doc.addObject(obj.type, obj.name)
set_object_property(doc, res, obj.properties)
FreeCAD.Console.PrintMessage(
f"{res.TypeId} '{res.Name}' added to '{doc.Name}' via RPC.\n"
)
return res
def create_object_gui(doc_name: str, obj: Object):
"""Create an object in ``doc_name`` according to ``obj.type``.
Returns the created object's actual ``Name`` on success (FreeCAD
sanitises and de-duplicates requested names — ``Box`` may come back as
``Box001`` — and every later get_object/edit_object call needs the real
one), or an error string on failure.
"""
try:
doc = FreeCAD.getDocument(doc_name)
except Exception:
FreeCAD.Console.PrintError(f"Document '{doc_name}' not found.\n")
return f"Document '{doc_name}' not found.\n"
try:
if obj.type == "Fem::FemMeshGmsh":
if not obj.analysis:
return (
"Fem::FemMeshGmsh requires an 'analysis_name' naming the "
"Fem::AnalysisPython container to add the mesh to."
)
created = _create_fem_mesh(doc, obj)
elif obj.type.startswith("Fem::"):
created = _create_fem_object(doc, obj)
else:
created = _create_generic_object(doc, obj)
doc.recompute()
problem = object_validity_error(created)
if problem:
FreeCAD.Console.PrintError(problem + "\n")
return {
"success": False,
"object_name": created.Name,
"error": problem,
}
return {"success": True, "object_name": created.Name}
except Exception as e:
return str(e)
def edit_object_gui(doc_name: str, obj: Object):
"""Apply properties to an existing object and verify the recomputed state."""
try:
doc = FreeCAD.getDocument(doc_name)
except Exception:
FreeCAD.Console.PrintError(f"Document '{doc_name}' not found.\n")
return f"Document '{doc_name}' not found.\n"
obj_ins = doc.getObject(obj.name)
if obj_ins is None:
FreeCAD.Console.PrintError(
f"Object '{obj.name}' not found in document '{doc_name}'.\n"
)
return f"Object '{obj.name}' not found in document '{doc_name}'.\n"
try:
set_object_property(doc, obj_ins, obj.properties)
doc.recompute()
problem = object_validity_error(obj_ins)
if problem:
FreeCAD.Console.PrintError(problem + "\n")
return {
"success": False,
"object_name": obj_ins.Name,
"error": problem,
}
FreeCAD.Console.PrintMessage(f"Object '{obj_ins.Name}' updated via RPC.\n")
return True
except Exception as e:
return str(e)
freecad-mcp-0.1.22/addon/FreeCADMCP/rpc_server/object_validation.py 0000664 0000000 0000000 00000004214 15241274622 0024772 0 ustar 00root root 0000000 0000000 """Post-recompute validity checks for FreeCAD document objects."""
from typing import Any
_FAILED_STATES = {"invalid", "error", "touched"}
def _object_states(obj: Any) -> list[str]:
"""Return FreeCAD's state labels without assuming a concrete container."""
try:
raw_state = obj.State
except Exception:
return []
if isinstance(raw_state, str):
return [raw_state]
try:
return [str(item) for item in raw_state]
except Exception:
return []
def object_validity_error(obj: Any) -> str | None:
"""Return a diagnostic when ``obj`` is invalid, otherwise ``None``.
Shape presence is deliberately not used as the discriminator. Containers,
groups, spreadsheets, and empty sketches can all be valid without a shape.
"""
name = str(getattr(obj, "Name", ""))
states = _object_states(obj)
failed_states = [
state for state in states if state.strip().casefold() in _FAILED_STATES
]
is_valid = getattr(obj, "isValid", None)
if callable(is_valid):
try:
is_valid_result = bool(is_valid())
except Exception as exc:
return (
f"Object '{name}' exists, but its validity could not be checked after "
f"recompute: {type(exc).__name__}: {exc}. Fix or remove the object "
"before building on it."
)
else:
is_valid_result = True
# FreeCAD's isValid() only reflects the Error bit. A feature can still be
# incomplete after recompute while its State reports Touched, so retain the
# explicit state check requested by issue #110.
if is_valid_result and not failed_states:
return None
get_status = getattr(obj, "getStatusString", None)
try:
reason = str(get_status()).strip() if callable(get_status) else ""
except Exception:
reason = ""
state = ", ".join(states) if states else "unknown"
detail = f": {reason}" if reason else ""
return (
f"Object '{name}' exists but failed to compute{detail} "
f"(State: {state}). Fix the cause or remove the object before building "
"on it."
)
freecad-mcp-0.1.22/addon/FreeCADMCP/rpc_server/parts_library.py 0000664 0000000 0000000 00000002337 15241274622 0024173 0 ustar 00root root 0000000 0000000 import os
import FreeCAD
import FreeCADGui
def insert_part_from_library(relative_path):
parts_lib_path = os.path.join(FreeCAD.getUserAppDataDir(), "Mod", "parts_library")
part_path = os.path.join(parts_lib_path, relative_path)
if not os.path.exists(part_path):
raise FileNotFoundError(f"Not found: {part_path}")
# mergeProject inserts into the active document; create one if none is open
# so we fail with a clear path instead of an AttributeError on None.
if FreeCADGui.ActiveDocument is None:
FreeCAD.newDocument()
FreeCADGui.ActiveDocument.mergeProject(part_path)
def get_parts_list() -> list[str]:
parts_lib_path = os.path.join(FreeCAD.getUserAppDataDir(), "Mod", "parts_library")
if not os.path.exists(parts_lib_path):
# Library addon not installed — return empty so the caller can show a
# friendly "no parts found" message instead of raising over XML-RPC.
return []
parts = []
for root, _, files in os.walk(parts_lib_path):
for file in files:
if file.endswith(".FCStd"):
relative_path = os.path.relpath(os.path.join(root, file), parts_lib_path)
parts.append(relative_path)
return parts
freecad-mcp-0.1.22/addon/FreeCADMCP/rpc_server/property_mapper.py 0000664 0000000 0000000 00000012245 15241274622 0024545 0 ustar 00root root 0000000 0000000 """Property assignment from JSON-friendly dicts onto FreeCAD document objects."""
from dataclasses import dataclass, field
from typing import Any
import FreeCAD
@dataclass
class Object:
name: str
type: str | None = None
analysis: str | None = None
properties: dict[str, Any] = field(default_factory=dict)
def _to_shape_color(val: Any) -> tuple[float, float, float, float]:
"""Normalise a color to a 4-float RGBA tuple.
Accepts RGB triples (alpha defaults to 1.0) and RGBA quads, matching what
FreeCAD's ``ShapeColor`` accepts.
"""
if not isinstance(val, (list, tuple)) or len(val) not in (3, 4):
raise ValueError(
f"ShapeColor must be an RGB or RGBA sequence, got {val!r}."
)
r, g, b = (float(val[0]), float(val[1]), float(val[2]))
a = float(val[3]) if len(val) == 4 else 1.0
return (r, g, b, a)
def parse_reference_entry(entry: Any) -> tuple[str, Any]:
"""Normalise a single ``References`` entry to ``(object_name, sub_element)``.
Accepts both the documented dict form
``{"object_name": "Box", "face": "Face1"}`` and the legacy
``["Box", "Face1"]`` pair form.
"""
if isinstance(entry, dict):
ref_name = entry.get("object_name", entry.get("Object"))
face = entry.get("face", entry.get("Face"))
if ref_name is None:
raise ValueError(
f"Reference entry {entry!r} is missing an 'object_name' key."
)
return ref_name, face
if isinstance(entry, (list, tuple)) and len(entry) == 2:
return entry[0], entry[1]
raise ValueError(
f"Invalid reference entry {entry!r}; expected "
"{'object_name': ..., 'face': ...} or [object_name, face]."
)
def resolve_references(doc: FreeCAD.Document, val: Any) -> list[tuple[Any, Any]]:
"""Resolve a ``References`` list into ``(DocumentObject, sub_element)`` tuples."""
refs = []
for entry in val:
ref_name, face = parse_reference_entry(entry)
ref_obj = doc.getObject(ref_name)
if ref_obj is None:
raise ValueError(f"Referenced object '{ref_name}' not found.")
refs.append((ref_obj, face))
return refs
def set_object_property(
doc: FreeCAD.Document, obj: FreeCAD.DocumentObject, properties: dict[str, Any]
):
failures = []
for prop, val in properties.items():
try:
if prop in obj.PropertiesList:
if prop == "Placement" and isinstance(val, dict):
if "Base" in val:
pos = val["Base"]
elif "Position" in val:
pos = val["Position"]
else:
pos = {}
rot = val.get("Rotation", {})
placement = FreeCAD.Placement(
FreeCAD.Vector(
pos.get("x", 0),
pos.get("y", 0),
pos.get("z", 0),
),
FreeCAD.Rotation(
FreeCAD.Vector(
rot.get("Axis", {}).get("x", 0),
rot.get("Axis", {}).get("y", 0),
rot.get("Axis", {}).get("z", 1),
),
rot.get("Angle", 0),
),
)
setattr(obj, prop, placement)
elif isinstance(getattr(obj, prop), FreeCAD.Vector) and isinstance(
val, dict
):
vector = FreeCAD.Vector(
val.get("x", 0), val.get("y", 0), val.get("z", 0)
)
setattr(obj, prop, vector)
elif prop in ["Base", "Tool", "Source", "Profile"] and isinstance(
val, str
):
ref_obj = doc.getObject(val)
if ref_obj:
setattr(obj, prop, ref_obj)
else:
raise ValueError(f"Referenced object '{val}' not found.")
elif prop == "References" and isinstance(val, list):
setattr(obj, prop, resolve_references(doc, val))
else:
setattr(obj, prop, val)
# ShapeColor is a property of the ViewObject
elif prop == "ShapeColor" and isinstance(val, (list, tuple)):
setattr(obj.ViewObject, prop, _to_shape_color(val))
elif prop == "ViewObject" and isinstance(val, dict):
for k, v in val.items():
if k == "ShapeColor":
setattr(obj.ViewObject, k, _to_shape_color(v))
else:
setattr(obj.ViewObject, k, v)
else:
setattr(obj, prop, val)
except Exception as e:
FreeCAD.Console.PrintError(f"Property '{prop}' assignment error: {e}\n")
failures.append(f"{prop}: {e}")
if failures:
raise ValueError(
"Failed to set propert" + ("y" if len(failures) == 1 else "ies")
+ ": " + "; ".join(failures)
)
freecad-mcp-0.1.22/addon/FreeCADMCP/rpc_server/rpc_server.py 0000664 0000000 0000000 00000040501 15241274622 0023463 0 ustar 00root root 0000000 0000000 import FreeCAD
import FreeCADGui
import contextlib
import base64
import io
import os
import tempfile
import threading
from typing import Any
from PySide import QtCore
from rpc_server.commands import register_commands, schedule_toggle_sync
from rpc_server.fem_executor import run_fem_analysis as _run_fem_analysis
from rpc_server.gui_dispatch import (
cleanup_waker,
dispatch_to_gui,
get_dispatch_status,
init_waker,
process_gui_tasks,
request_shutdown,
)
from rpc_server.ip_filter import FilteredXMLRPCServer, validate_allowed_ips
from rpc_server.object_factory import create_object_gui, edit_object_gui
from rpc_server.parts_library import get_parts_list, insert_part_from_library
from rpc_server.property_mapper import Object
from rpc_server.serialize import serialize_object
from rpc_server.settings import load_settings, save_settings
from rpc_server.view_manager import save_active_screenshot
rpc_server_thread = None
rpc_server_instance = None
_stop_thread = None # drains shutdown off the GUI thread; see stop_rpc_server
def _ok(res) -> bool:
"""True when a GUI-thread handler returned success."""
return res is True
def _err(res) -> dict:
"""Convert any non-True result (error string or timeout dict) to a failure dict."""
if isinstance(res, dict):
return res
return {"success": False, "error": str(res)}
class FreeCADRPC:
"""RPC server for FreeCAD"""
TIMEOUT = 60 # generous wait for GUI thread to become free
EXECUTE_CODE_TIMEOUT = 90 # GUI-thread execution; use execute_code_async for heavy OCCT ops
def ping(self):
return True
def get_rpc_status(self) -> dict[str, Any]:
"""Report server and GUI-dispatch health without using the GUI thread."""
return {
"success": True,
"rpc_server": "running",
"gui_dispatch": get_dispatch_status(),
}
def create_document(self, name="New_Document"):
# The GUI handler reports the document's ACTUAL name — FreeCAD
# sanitises requested names ("My Doc" -> "My_Doc") and de-duplicates
# ("Doc" -> "Doc001"); reporting the requested name breaks every
# follow-up call that uses it.
res = dispatch_to_gui(
lambda: self._create_document_gui(name),
operation_name="create_document",
)
if isinstance(res, dict) and res.get("success"):
return res
return _err(res)
def create_object(self, doc_name, obj_data: dict[str, Any]):
obj = Object(
name=obj_data.get("Name", "New_Object"),
type=obj_data["Type"],
analysis=obj_data.get("Analysis", None),
properties=obj_data.get("Properties", {}),
)
# create_object_gui reports the created object's actual Name (see
# its docstring) — same sanitise/de-duplicate concern as documents.
res = dispatch_to_gui(
lambda: self._create_object_gui(doc_name, obj),
operation_name="create_object",
)
if isinstance(res, dict) and res.get("success"):
return res
return _err(res)
def edit_object(self, doc_name: str, obj_name: str, properties: dict[str, Any]) -> dict[str, Any]:
obj = Object(
name=obj_name,
properties=properties.get("Properties", {}),
)
res = dispatch_to_gui(
lambda: self._edit_object_gui(doc_name, obj),
operation_name="edit_object",
)
if _ok(res):
return {"success": True, "object_name": obj.name}
return _err(res)
def delete_object(self, doc_name: str, obj_name: str):
res = dispatch_to_gui(
lambda: self._delete_object_gui(doc_name, obj_name),
operation_name="delete_object",
)
if _ok(res):
return {"success": True, "object_name": obj_name}
return _err(res)
def reload_document(self, doc_name: str) -> dict[str, Any]:
"""Close and re-open a document by name to pick up external file
changes (e.g. edits made by another process such as `freecadcmd`
running headlessly). Returns success once the new document is
loaded from disk.
"""
res = dispatch_to_gui(
lambda: self._reload_document_gui(doc_name),
operation_name="reload_document",
)
if _ok(res):
return {"success": True, "document_name": doc_name}
return _err(res)
def run_fem_analysis(self, doc_name: str, analysis_name: str, timeout: int = 600) -> dict[str, Any]:
"""Run the CalculiX solver on an existing Fem::FemAnalysis and return summary results."""
try:
timeout_s = int(timeout)
except (TypeError, ValueError):
return {"success": False, "error": f"invalid timeout: {timeout!r}"}
res = dispatch_to_gui(
lambda: self._run_fem_analysis_gui(doc_name, analysis_name),
timeout=timeout_s,
operation_name="run_fem_analysis",
)
if isinstance(res, dict):
return res
return {"success": False, "error": str(res)}
def execute_code_async(self, code: str) -> dict[str, Any]:
"""Start code execution in a background thread and return immediately.
Use for long-running OCCT operations (fuse/cut/loft) that would otherwise
exceed the MCP timeout. The caller should poll a document object for
completion status (e.g. check SessionState.Label via get_object).
"""
def _set_status(msg):
dispatch_to_gui(
lambda: FreeCADGui.getMainWindow().statusBar().showMessage(msg),
operation_name="show_async_status",
)
def _clear_status():
dispatch_to_gui(
lambda: FreeCADGui.getMainWindow().statusBar().clearMessage(),
operation_name="clear_async_status",
)
def worker() -> None:
# NOTE: we do NOT redirect sys.stdout here. contextlib.redirect_stdout
# swaps stdout process-wide, not per-thread, so it would race with the
# GUI thread and other concurrent work. Background code should report
# via FreeCAD.Console (which is thread-safe) instead.
try:
exec(code, globals())
FreeCAD.Console.PrintMessage("Async code execution completed.\n")
except Exception as e:
import traceback as _tb
FreeCAD.Console.PrintError(
f"Async code error: {e}\n{_tb.format_exc()}"
)
finally:
_clear_status()
_set_status("MCP: running background task…")
threading.Thread(target=worker, daemon=True).start()
return {"success": True, "message": "Code execution started in background."}
def execute_code(self, code: str) -> dict[str, Any]:
"""Execute Python code on the GUI thread and wait for the result.
Runs on the GUI thread so that FreeCAD document operations
(addObject, recompute, save) are safe and correctly ordered.
Use execute_code_async for heavy OCCT boolean ops (fuse/cut)
that would block the GUI thread too long.
"""
output_buffer = io.StringIO()
def task():
with contextlib.redirect_stdout(output_buffer):
exec(code, globals())
return True
res = dispatch_to_gui(
task,
timeout=self.EXECUTE_CODE_TIMEOUT,
operation_name="execute_code",
)
if _ok(res):
FreeCAD.Console.PrintMessage("Python code executed successfully.\n")
return {
"success": True,
"message": "Python code executed successfully.\nOutput: " + output_buffer.getvalue(),
}
# Log the offending code (truncated) to make errors traceable
code_preview = code if len(code) <= 800 else code[:800] + "\n...(truncated)"
FreeCAD.Console.PrintError(
f"Error executing Python code: {res}\n"
f"--- code ---\n{code_preview}\n--- end ---\n"
)
return _err(res)
def get_objects(self, doc_name):
# FreeCAD.getDocument raises (not returns None) for an unknown name.
try:
doc = FreeCAD.getDocument(doc_name)
except Exception:
return []
return [serialize_object(obj) for obj in doc.Objects]
def get_object(self, doc_name, obj_name):
# FreeCAD.getDocument raises (not returns None) for an unknown name.
try:
doc = FreeCAD.getDocument(doc_name)
except Exception:
return None
obj = doc.getObject(obj_name)
if obj:
return serialize_object(obj)
return None
def insert_part_from_library(self, relative_path):
res = dispatch_to_gui(
lambda: self._insert_part_from_library(relative_path),
operation_name="insert_part_from_library",
)
if _ok(res):
return {"success": True, "message": "Part inserted from library."}
return _err(res)
def list_documents(self):
return list(FreeCAD.listDocuments().keys())
def get_parts_list(self):
return get_parts_list()
def get_active_screenshot(
self,
view_name: str = "Isometric",
width: int | None = None,
height: int | None = None,
focus_object: str | None = None,
) -> str:
"""Get a screenshot of the active view as a base64-encoded PNG string.
Returns None if the active view does not support screenshots
(e.g., TechDraw or Spreadsheet workbench).
"""
fd, tmp_path = tempfile.mkstemp(suffix=".png")
os.close(fd)
def task():
try:
active_view = FreeCADGui.ActiveDocument.ActiveView
except Exception:
return False
if active_view is None or not hasattr(active_view, "saveImage"):
view_type = type(active_view).__name__ if active_view is not None else "None"
FreeCAD.Console.PrintWarning(
f"MCP RPC: view type '{view_type}' does not support screenshots\n"
)
return False
return save_active_screenshot(tmp_path, view_name, width, height, focus_object)
try:
res = dispatch_to_gui(task, operation_name="get_active_screenshot")
if _ok(res):
with open(tmp_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
if res is False:
return None
FreeCAD.Console.PrintWarning(f"MCP RPC: screenshot failed: {res}\n")
return None
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
def _create_document_gui(self, name):
doc = FreeCAD.newDocument(name)
doc.recompute()
FreeCAD.Console.PrintMessage(f"Document '{doc.Name}' created via RPC.\n")
return {"success": True, "document_name": doc.Name}
def _create_object_gui(self, doc_name, obj: Object):
return create_object_gui(doc_name, obj)
def _edit_object_gui(self, doc_name: str, obj: Object):
return edit_object_gui(doc_name, obj)
def _run_fem_analysis_gui(self, doc_name: str, analysis_name: str):
return _run_fem_analysis(doc_name, analysis_name)
def _delete_object_gui(self, doc_name: str, obj_name: str):
try:
doc = FreeCAD.getDocument(doc_name)
except Exception:
FreeCAD.Console.PrintError(f"Document '{doc_name}' not found.\n")
return f"Document '{doc_name}' not found.\n"
try:
doc.removeObject(obj_name)
doc.recompute()
FreeCAD.Console.PrintMessage(f"Object '{obj_name}' deleted via RPC.\n")
return True
except Exception as e:
return str(e)
def _reload_document_gui(self, doc_name: str):
if doc_name not in FreeCAD.listDocuments():
return f"Document '{doc_name}' is not loaded."
doc = FreeCAD.getDocument(doc_name)
file_path = doc.FileName
if not file_path:
return (
f"Document '{doc_name}' has no file on disk "
"(unsaved scratch document); nothing to reload from."
)
if not os.path.exists(file_path):
return f"File for '{doc_name}' not found at {file_path!r}."
# Close, then reopen from the same file. Reopen preserves the
# original document name when the file was previously saved
# under that name.
FreeCAD.closeDocument(doc_name)
FreeCAD.openDocument(file_path)
FreeCAD.Console.PrintMessage(
f"Document '{doc_name}' reloaded from '{file_path}' via RPC.\n"
)
return True
def _insert_part_from_library(self, relative_path):
try:
insert_part_from_library(relative_path)
return True
except Exception as e:
return str(e)
def _save_active_screenshot(
self,
save_path: str,
view_name: str = "Isometric",
width: int | None = None,
height: int | None = None,
focus_object: str | None = None,
):
return save_active_screenshot(save_path, view_name, width, height, focus_object)
def start_rpc_server(port=9875):
global rpc_server_thread, rpc_server_instance
if rpc_server_instance:
return "RPC Server already running."
# A previous stop may still be draining an in-flight request off-thread;
# binding before its server_close() would hit the old socket.
if _stop_thread is not None and _stop_thread.is_alive():
_stop_thread.join(timeout=5.0)
if _stop_thread.is_alive():
return ("RPC Server is still stopping (a request is draining); "
"try again in a few seconds.")
settings = load_settings()
remote_enabled = settings.get("remote_enabled", False)
allowed_ips = settings.get("allowed_ips", "127.0.0.1")
if remote_enabled:
host = "0.0.0.0"
else:
host = "127.0.0.1"
rpc_server_instance = FilteredXMLRPCServer(
(host, port), allowed_ips_str=allowed_ips, allow_none=True, logRequests=False
)
rpc_server_instance.register_instance(FreeCADRPC())
def server_loop():
FreeCAD.Console.PrintMessage(f"RPC Server started at {host}:{port}\n")
if remote_enabled:
FreeCAD.Console.PrintMessage(f"Remote connections enabled. Allowed IPs: {allowed_ips}\n")
rpc_server_instance.serve_forever()
rpc_server_thread = threading.Thread(target=server_loop, daemon=True)
rpc_server_thread.start()
init_waker()
QtCore.QTimer.singleShot(500, process_gui_tasks)
msg = f"RPC Server started at {host}:{port}."
if remote_enabled:
msg += f" Allowed IPs: {allowed_ips}"
return msg
def stop_rpc_server():
global rpc_server_instance, rpc_server_thread, _stop_thread
if not rpc_server_instance:
return "RPC Server was not running."
server = rpc_server_instance
thread = rpc_server_thread
rpc_server_instance = None
rpc_server_thread = None
request_shutdown()
cleanup_waker()
def _shutdown_and_close():
# shutdown() blocks until serve_forever drains the in-flight request,
# and that request may itself be waiting on dispatch_to_gui — running
# this on the GUI thread (menu command) froze the UI for up to the
# dispatch timeout. server_close() must always follow: without it the
# listening socket stays bound and Stop -> Start fails with
# EADDRINUSE (the restart the README asks for after changing Remote
# Connections or Allowed IPs).
try:
server.shutdown()
if thread is not None:
thread.join(timeout=10.0)
if thread.is_alive():
FreeCAD.Console.PrintWarning(
"MCP RPC: server thread still draining a request; "
"socket closes when it finishes.\n"
)
finally:
server.server_close()
FreeCAD.Console.PrintMessage("RPC Server stopped.\n")
_stop_thread = threading.Thread(target=_shutdown_and_close, daemon=True)
_stop_thread.start()
return "RPC Server stopping…"
register_commands()
schedule_toggle_sync()
freecad-mcp-0.1.22/addon/FreeCADMCP/rpc_server/serialize.py 0000664 0000000 0000000 00000006016 15241274622 0023303 0 ustar 00root root 0000000 0000000 import FreeCAD as App
import json
def _get_optional_app_type(name: str) -> type | tuple[type, ...] | None:
value = getattr(App, name, None)
if isinstance(value, type):
return value
if isinstance(value, tuple) and all(isinstance(item, type) for item in value):
return value
return None
_COLOR_TYPE = _get_optional_app_type("Color")
def serialize_value(value):
if isinstance(value, (int, float, str, bool)):
return value
elif isinstance(value, App.Vector):
return {"x": value.x, "y": value.y, "z": value.z}
elif isinstance(value, App.Rotation):
return {
"Axis": {"x": value.Axis.x, "y": value.Axis.y, "z": value.Axis.z},
"Angle": value.Angle,
}
elif isinstance(value, App.Placement):
return {
"Base": serialize_value(value.Base),
"Rotation": serialize_value(value.Rotation),
}
elif isinstance(value, (list, tuple)):
return [serialize_value(v) for v in value]
elif _COLOR_TYPE is not None and isinstance(value, _COLOR_TYPE):
return tuple(value)
else:
return str(value)
def serialize_shape(shape):
if shape is None:
return None
try:
return {
"Volume": shape.Volume,
"Area": shape.Area,
"VertexCount": len(shape.Vertexes),
"EdgeCount": len(shape.Edges),
"FaceCount": len(shape.Faces),
}
except Exception as e:
return {"error": f"invalid shape: {str(e)}"}
def serialize_view_object(view):
if view is None:
return None
result = {}
try:
result["ShapeColor"] = serialize_value(view.ShapeColor)
except AttributeError:
pass
try:
result["Transparency"] = view.Transparency
except AttributeError:
pass
try:
result["Visibility"] = view.Visibility
except AttributeError:
pass
return result
def serialize_object(obj):
if isinstance(obj, list):
return [serialize_object(item) for item in obj]
elif isinstance(obj, App.Document):
return {
"Name": obj.Name,
"Label": obj.Label,
"FileName": obj.FileName,
"Objects": [serialize_object(child) for child in obj.Objects],
}
else:
result = {
"Name": obj.Name,
"Label": obj.Label,
"TypeId": obj.TypeId,
"Properties": {},
"Placement": serialize_value(getattr(obj, "Placement", None)),
"Shape": serialize_shape(getattr(obj, "Shape", None)),
"ViewObject": {},
}
for prop in obj.PropertiesList:
try:
result["Properties"][prop] = serialize_value(getattr(obj, prop))
except Exception as e:
result["Properties"][prop] = f""
if hasattr(obj, "ViewObject") and obj.ViewObject is not None:
view = obj.ViewObject
result["ViewObject"] = serialize_view_object(view)
return result
freecad-mcp-0.1.22/addon/FreeCADMCP/rpc_server/settings.py 0000664 0000000 0000000 00000002150 15241274622 0023147 0 ustar 00root root 0000000 0000000 """Persistence of MCP RPC server settings under FreeCAD's user app data dir."""
import json
import os
import FreeCAD
_SETTINGS_FILENAME = "freecad_mcp_settings.json"
_DEFAULT_SETTINGS = {
"remote_enabled": False,
"allowed_ips": "127.0.0.1",
"auto_start_rpc": False,
}
def _get_settings_path():
return os.path.join(FreeCAD.getUserAppDataDir(), _SETTINGS_FILENAME)
def load_settings():
path = _get_settings_path()
if os.path.exists(path):
try:
with open(path, "r") as f:
settings = json.load(f)
for key, value in _DEFAULT_SETTINGS.items():
if key not in settings:
settings[key] = value
return settings
except Exception as e:
FreeCAD.Console.PrintWarning(f"Failed to load MCP settings: {e}\n")
return dict(_DEFAULT_SETTINGS)
def save_settings(settings):
path = _get_settings_path()
try:
with open(path, "w") as f:
json.dump(settings, f, indent=2)
except Exception as e:
FreeCAD.Console.PrintError(f"Failed to save MCP settings: {e}\n")
freecad-mcp-0.1.22/addon/FreeCADMCP/rpc_server/view_manager.py 0000664 0000000 0000000 00000013173 15241274622 0023762 0 ustar 00root root 0000000 0000000 """Active-view orientation, sizing, and screenshot capture."""
from typing import Any
import FreeCAD
import FreeCADGui
from rpc_server.gui_dispatch import _flush_gui_events
_VIEW_DISPATCH = {
"Isometric": "viewIsometric",
"Front": "viewFront",
"Top": "viewTop",
"Right": "viewRight",
"Back": "viewBack",
"Left": "viewLeft",
"Bottom": "viewBottom",
"Dimetric": "viewDimetric",
"Trimetric": "viewTrimetric",
}
def _get_view_size(view: Any) -> tuple[int, int]:
try:
size = view.getSize()
if isinstance(size, (list, tuple)) and len(size) >= 2:
return max(1, int(size[0])), max(1, int(size[1]))
return max(1, int(size.width())), max(1, int(size.height()))
except Exception:
return 1024, 768
# Longest edge used when the caller does not ask for a specific size. The
# screenshot's cost to an LLM client scales with its pixel count, and hosts
# commonly downscale anything larger than ~1.5k px before the model ever sees
# it, so rendering at the full window size just inflates the payload. An
# explicit width/height is always honoured as given.
MAX_AUTO_SCREENSHOT_EDGE = 1024
def _scale_to_max_edge(width: int, height: int, max_edge: int) -> tuple[int, int]:
longest = max(width, height)
if longest <= max_edge:
return width, height
scale = max_edge / longest
return max(1, int(width * scale)), max(1, int(height * scale))
def _resolve_screenshot_size(
view: Any,
width: int | None,
height: int | None,
) -> tuple[int, int]:
view_width, view_height = _get_view_size(view)
if width is None and height is None:
return _scale_to_max_edge(view_width, view_height, MAX_AUTO_SCREENSHOT_EDGE)
resolved_width = view_width if width is None else max(1, int(width))
resolved_height = view_height if height is None else max(1, int(height))
return resolved_width, resolved_height
_STD_COMMAND_DISPATCH = {
"Isometric": "Std_ViewIsometric",
"Front": "Std_ViewFront",
"Top": "Std_ViewTop",
"Right": "Std_ViewRight",
"Back": "Std_ViewRear",
"Left": "Std_ViewLeft",
"Bottom": "Std_ViewBottom",
"Dimetric": "Std_ViewDimetric",
"Trimetric": "Std_ViewTrimetric",
}
def apply_view_orientation(view: Any, view_name: str) -> None:
method_name = _VIEW_DISPATCH.get(view_name)
if method_name is None:
raise ValueError(f"Invalid view name: {view_name}")
if hasattr(view, method_name):
getattr(view, method_name)()
else:
# Fallback for views that lack the direct Python method
# (e.g. some FreeCAD versions / view types)
cmd = _STD_COMMAND_DISPATCH.get(view_name)
if cmd:
FreeCADGui.runCommand(cmd)
else:
FreeCAD.Console.PrintWarning(
f"apply_view_orientation: no method or command for '{view_name}'\n"
)
def save_active_screenshot(
save_path: str,
view_name: str = "Isometric",
width: int | None = None,
height: int | None = None,
focus_object: str | None = None,
):
"""Save a PNG of the active view to ``save_path``.
Returns ``True`` on success, or an error string on failure (preserves the
legacy GUI-handler return contract).
"""
try:
view = FreeCADGui.ActiveDocument.ActiveView
if not hasattr(view, "saveImage"):
return "Current view does not support screenshots"
apply_view_orientation(view, view_name)
focused_selection = False
# The resolved object we frame on (when focus_object is given), kept so
# the framing can be re-applied synchronously right before saveImage().
focus_target = None
if focus_object:
doc = FreeCAD.ActiveDocument
obj = doc.getObject(focus_object) if doc else None
if obj:
FreeCADGui.Selection.clearSelection()
FreeCADGui.Selection.addSelection(obj)
FreeCADGui.SendMsgToActiveView("ViewSelection")
focused_selection = True
focus_target = obj
_flush_gui_events()
FreeCADGui.Selection.clearSelection()
else:
view.fitAll()
else:
view.fitAll()
_flush_gui_events()
# On macOS, when the FreeCAD window is not exposed (fully occluded or
# minimized), saveImage() right after pumping the event loop grabs a blank
# frame. Re-issuing the framing synchronously forces a redraw first. The
# flush above is kept intentionally — Linux needs it for the stale-frame
# fix (#51/#53).
if focused_selection and focus_target is not None:
FreeCADGui.Selection.addSelection(focus_target)
FreeCADGui.SendMsgToActiveView("ViewSelection")
FreeCADGui.Selection.clearSelection()
else:
view.fitAll()
resolved_width, resolved_height = _resolve_screenshot_size(view, width, height)
# On Wayland the offscreen GL contexts used by the default saveImage()
# method render solid black; "Framebuffer" reads back the on-screen GL
# context and captures correctly (and also works on X11/Windows/macOS).
# FreeCAD < 1.0 lacks the method argument — fall back to the legacy call.
try:
view.saveImage(save_path, resolved_width, resolved_height, "Current", "Framebuffer")
except TypeError:
view.saveImage(save_path, resolved_width, resolved_height, "Current")
if focused_selection:
FreeCADGui.Selection.clearSelection()
_flush_gui_events(delay_ms=0)
return True
except Exception as e:
return str(e)
freecad-mcp-0.1.22/assets/ 0000775 0000000 0000000 00000000000 15241274622 0015251 5 ustar 00root root 0000000 0000000 freecad-mcp-0.1.22/assets/b9-1.png 0000664 0000000 0000000 00000125114 15241274622 0016433 0 ustar 00root root 0000000 0000000 ‰PNG
IHDR è þ#¹ì ÈzTXtRaw profile type exif xÚmP[!ü÷=2èâqÜG“Þ Ç/
Ûì6%qB:Þ¯gzã,IÊ¢µÕJfÒ¤q7¢äÖ'f’‰îlÓþ-ž%žŒ-»á ñðŒÇƒ¯`7V.BºEb½'š÷%Ö!oK?|¡B`Oäè>Õ¦Ëu„õ »©Ÿ4€·(‹â__ÛÞ^¬˜d�!Pý§$t#2‘0£š¡àÉòoO§¥«pYÙ(½ŠØ "iCCPICC profile xœ��±JÃP†¿ÖREt²8ˆCÁ©£™¬
A°c«Sš´XÌ�!I)¾�o¢ÓA|WgÿÌâ…Ãÿq8çÿï½Ðt’È0i™{Aox9¼r–ßhѤÍ.�0*²žïŸR{>_iX}éZ¯ú¹?O;‘t¡J£,/¡±/vçefYEçv‰ÄNlÒXü$Þ‰MlÙî&™E?žö6kãôâÜöUÛxœÐÇÇaÄŒ) %]iªÎ1.{R�œ�{
"iÂX½¹fJnD…œ<E‘nS“·UåùJÉc*/›p‡‘§ÍÃþï÷ÚÇYµÙØ\daV%Us2�÷GXÂÆ3¬^×dü~[ÍŒ[Íüó�_àP`Žqþ.
xiTXtXML:com.adobe.xmp
2ôã pHYs .# .#x¥?v tIMEé'/×�}/ IDATxÚì�yœ¤E‘÷‘ÏSÕÝs2ÜÂpÉ-È-°ryá½êªëz¢Èâz ®È¾r 꺈»®«®®®âÅz!( ¢ ¨È�0\sww=OFÄûGõL÷Ò=3=NUu|?…é©~¨'3ò—‘‘™ä‚ XŽ&‚ Ä!‚‡ B‚ q‚ Ä!‚‡ B‚ q‚ Ä!‚‡ B‚ q‚ q‚ Ä!‚‡ B‚ q‚ Ä!‚‡ B‚ q‚ Ä!‚‡ B‚ B‚ q‚ Ä!‚‡ B‚ q‚ KÄA�GKA¡@
è~†|¼§7á•hÏ èŒaDu±Aâà„šC‚ ·¨Êñ¦üqÅ!'ÁÖåP$'‚ ‡øã…O®J¯Oô™4ÞCl«O„ç=ÅXKPã Ê4þc˜ääÑžAÐ34D€ÌÅyuárªÊhÏ è¼,Áé‰Ü‚ñR8'MІ è!Ø À" 'A#Z3z- aÃÄ!‚©é]DAâAˆC!A„8AâAˆC!A„8AâAˆC!A„8A„8AâAˆC!A„8AâAˆC!A„8AâAˆC!A!A„8AâAˆC!A„8AâAˆC!A„8AâAˆCAˆC!A„8AâAˆC!A„8AâAˆC!=GŠ{¦ÓÇp»p×¾íß>¸áUhuî‹¶ãCd¬ý¹>üþçîHAÔøäžÍyïXW�NY[‹82€ui(ƒÃÔ@FV #ÿßþYâ°5.8EÑd:óÔ�Î=îãÇÚO©xð_®ÞW ¸"½}õç:`õ¹f/ndÉ@ñÞœwαùÛ%$„*:fUn;ûðÙ›ô?ÊžÅ?ºWc‡‚¡¦‰?ÀÙ€ß�}Ð칇ý·’¸}bþæsOkÕ†áhÞ‘6{£O!ÔÝ=½<¸úütw?�¾¸ÁÏm™·îqï?нöú�ýky®™ku—{ó@w7¿£8¡v?_tˬÆëÒqï|û.x‰f÷Ó‹c?úz®µ×þýÊÝ<¿±<þ�§îF/ôJõ½8î#oÄ¡êµ[ï·ß‡os÷q^4ÄaÍ÷Õ¼l‹w¸ùåRóüx:¾ÞpÕv÷Ü÷TW÷úßðŸ›uÆa:lîÿ†KÔõñôtWW
9X•ËîËnù\ãúûþ#³úÙüe;í”Õýû÷ÔîCðs÷;Šgdõ³Šÿ®Ü‡=‡8¸GX±:CÄþÏÓÞkM3µz¨Afï{]Úð ®¡p6†¦ŸaõçúŠˆYȇ¹AºG0xö~¿�³*'l# {½Ü
þÖໄñ6ù–zÂö¬ 'ÌK-¤×ãÃWëS…ò[í‹´‰ŽœÃšôUøÍ…šîɳ?>c¦(°ÅðÒ
ßLjˆàŒûúW.H<