* chore: rename workbench to FreecadRobustMCPBridge * fix: stdio cleanup and bug fix for JSON RPC * fix: update FreeCAD MCP bridge and tests * test: Fix GUI Integration tests and other issues * fix: unit tests in CI and a few other things * docs: generate GitHub pages site and link to it. * fix: General code improvements and DRY refactoring * chore: General cleanup * chore: small fixes * docs: cleanup * docs: fixes * docs: small corrections * docs: fix * test: release check * bump: workbench v0.6.0 * chore: bump macros to 0.6.0 * docs: Release improvements * refactor: improve DRYness of code
830 lines
22 KiB
Python
830 lines
22 KiB
Python
"""Integration tests for FreeCAD Robust MCP Bridge functionality.
|
|
|
|
These tests verify that the MCP bridge works correctly with FreeCAD, including
|
|
object creation, manipulation, and export functionality. Most tests work in
|
|
both GUI and headless modes, with specific tests marked as headless-only.
|
|
|
|
Note: These tests require a running FreeCAD server.
|
|
Start with: just freecad::run-gui (GUI mode)
|
|
or: just freecad::run-headless (headless mode)
|
|
|
|
To run these tests:
|
|
pytest tests/integration/test_headless_mode.py -v
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import pytest
|
|
|
|
from tests.integration.conftest import requires_headless
|
|
|
|
if TYPE_CHECKING:
|
|
import xmlrpc.client
|
|
from collections.abc import Generator
|
|
|
|
# Mark all tests in this module as integration tests
|
|
pytestmark = pytest.mark.integration
|
|
|
|
# Note: xmlrpc_proxy fixture is defined in conftest.py
|
|
|
|
|
|
def _unique_suffix() -> str:
|
|
"""Generate a unique suffix using timestamp."""
|
|
return time.strftime("%Y%m%d%H%M%S")
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def temp_dir() -> Generator[str, None, None]:
|
|
"""Create a temporary directory for test files."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
yield tmpdir
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def unique_suffix() -> str:
|
|
"""Generate a unique suffix for document names in this test session.
|
|
|
|
Uses timestamp format YYYYMMDDHHMMSS to ensure unique names across runs.
|
|
"""
|
|
return _unique_suffix()
|
|
|
|
|
|
def execute_code(proxy: xmlrpc.client.ServerProxy, code: str) -> dict[str, Any]:
|
|
"""Execute Python code via the MCP bridge and return the result."""
|
|
result: dict[str, Any] = proxy.execute(code) # type: ignore[assignment]
|
|
assert result.get("success"), f"Execution failed: {result.get('error_traceback')}"
|
|
return result
|
|
|
|
|
|
# Common code snippet for centering viewport in GUI mode
|
|
CENTER_VIEWPORT_CODE = """
|
|
# Center viewport for GUI mode recording
|
|
if FreeCAD.GuiUp:
|
|
import FreeCADGui
|
|
if FreeCADGui.ActiveDocument and FreeCADGui.ActiveDocument.ActiveView:
|
|
FreeCADGui.ActiveDocument.ActiveView.viewIsometric()
|
|
FreeCADGui.ActiveDocument.ActiveView.fitAll()
|
|
"""
|
|
|
|
|
|
def center_viewport(proxy: xmlrpc.client.ServerProxy) -> None:
|
|
"""Center all objects in the viewport if GUI is available.
|
|
|
|
Call this after creating/modifying objects to keep them centered.
|
|
Safe to call in headless mode - it will simply do nothing.
|
|
"""
|
|
proxy.execute( # type: ignore[union-attr]
|
|
f"""
|
|
import FreeCAD
|
|
{CENTER_VIEWPORT_CODE}
|
|
_result_ = True
|
|
"""
|
|
)
|
|
|
|
|
|
class TestHeadlessConnection:
|
|
"""Tests for basic headless mode connectivity."""
|
|
|
|
def test_ping(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
|
"""Test that the server responds to ping."""
|
|
result: dict[str, Any] = xmlrpc_proxy.ping() # type: ignore[assignment]
|
|
assert result["pong"] is True
|
|
assert "timestamp" in result
|
|
|
|
@requires_headless
|
|
def test_headless_mode_detected(
|
|
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
|
|
) -> None:
|
|
"""Test that FreeCAD is running in headless mode (GuiUp=False).
|
|
|
|
This test is skipped when FreeCAD is running in GUI mode.
|
|
"""
|
|
result = execute_code(
|
|
xmlrpc_proxy,
|
|
"""
|
|
import FreeCAD
|
|
_result_ = {"gui_up": FreeCAD.GuiUp}
|
|
""",
|
|
)
|
|
# In headless mode, GuiUp should be False (0)
|
|
assert result["result"]["gui_up"] == 0 or result["result"]["gui_up"] is False
|
|
|
|
|
|
class TestDocumentManagement:
|
|
"""Tests for document creation and management in headless mode."""
|
|
|
|
def test_create_document(
|
|
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, unique_suffix: str
|
|
) -> None:
|
|
"""Test creating a new document."""
|
|
doc_name = f"TestDoc_{unique_suffix}"
|
|
result = execute_code(
|
|
xmlrpc_proxy,
|
|
f"""
|
|
import FreeCAD
|
|
doc_name = {doc_name!r}
|
|
# Close if it already exists (from a previous failed run)
|
|
if doc_name in FreeCAD.listDocuments():
|
|
FreeCAD.closeDocument(doc_name)
|
|
doc = FreeCAD.newDocument(doc_name)
|
|
_result_ = {{"name": doc.Name, "object_count": len(doc.Objects)}}
|
|
""",
|
|
)
|
|
assert result["result"]["name"] == doc_name
|
|
assert result["result"]["object_count"] == 0
|
|
|
|
def test_list_documents(
|
|
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, unique_suffix: str
|
|
) -> None:
|
|
"""Test listing open documents."""
|
|
doc_name = f"ListTestDoc_{unique_suffix}"
|
|
# First create a document
|
|
execute_code(
|
|
xmlrpc_proxy,
|
|
f"""
|
|
import FreeCAD
|
|
doc_name = {doc_name!r}
|
|
if doc_name in FreeCAD.listDocuments():
|
|
FreeCAD.closeDocument(doc_name)
|
|
FreeCAD.newDocument(doc_name)
|
|
_result_ = True
|
|
""",
|
|
)
|
|
|
|
result = execute_code(
|
|
xmlrpc_proxy,
|
|
"""
|
|
import FreeCAD
|
|
docs = list(FreeCAD.listDocuments().keys())
|
|
_result_ = {"documents": docs, "count": len(docs)}
|
|
""",
|
|
)
|
|
assert result["result"]["count"] >= 1
|
|
|
|
def test_close_document(
|
|
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, unique_suffix: str
|
|
) -> None:
|
|
"""Test closing a document."""
|
|
doc_name = f"ToClose_{unique_suffix}"
|
|
# Create a document to close
|
|
execute_code(
|
|
xmlrpc_proxy,
|
|
f"""
|
|
import FreeCAD
|
|
doc_name = {doc_name!r}
|
|
if doc_name in FreeCAD.listDocuments():
|
|
FreeCAD.closeDocument(doc_name)
|
|
doc = FreeCAD.newDocument(doc_name)
|
|
_result_ = True
|
|
""",
|
|
)
|
|
|
|
result = execute_code(
|
|
xmlrpc_proxy,
|
|
f"""
|
|
import FreeCAD
|
|
doc_name = {doc_name!r}
|
|
FreeCAD.closeDocument(doc_name)
|
|
_result_ = {{"closed": doc_name not in FreeCAD.listDocuments()}}
|
|
""",
|
|
)
|
|
assert result["result"]["closed"] is True
|
|
|
|
|
|
class TestPrimitiveCreation:
|
|
"""Tests for creating primitive shapes."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def setup_document(
|
|
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, unique_suffix: str
|
|
) -> None:
|
|
"""Create a fresh document for each test."""
|
|
doc_name = f"PrimitiveTestDoc_{unique_suffix}"
|
|
execute_code(
|
|
xmlrpc_proxy,
|
|
f"""
|
|
import FreeCAD
|
|
doc_name = {doc_name!r}
|
|
# Close any existing document with this name
|
|
if doc_name in FreeCAD.listDocuments():
|
|
FreeCAD.closeDocument(doc_name)
|
|
doc = FreeCAD.newDocument(doc_name)
|
|
|
|
# Set up initial view for GUI mode
|
|
if FreeCAD.GuiUp:
|
|
import FreeCADGui
|
|
FreeCADGui.ActiveDocument.ActiveView.viewIsometric()
|
|
|
|
_result_ = True
|
|
""",
|
|
)
|
|
|
|
def test_create_box(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
|
"""Test creating a Part::Box primitive."""
|
|
result = execute_code(
|
|
xmlrpc_proxy,
|
|
"""
|
|
import FreeCAD
|
|
import Part
|
|
|
|
doc = FreeCAD.ActiveDocument
|
|
box = Part.makeBox(10, 20, 30)
|
|
obj = doc.addObject("Part::Feature", "TestBox")
|
|
obj.Shape = box
|
|
|
|
# Center viewport immediately after adding object (before it renders off-center)
|
|
if FreeCAD.GuiUp:
|
|
import FreeCADGui
|
|
FreeCADGui.ActiveDocument.ActiveView.fitAll()
|
|
|
|
doc.recompute()
|
|
|
|
# Final viewport adjustment
|
|
if FreeCAD.GuiUp:
|
|
FreeCADGui.ActiveDocument.ActiveView.fitAll()
|
|
|
|
_result_ = {
|
|
"name": obj.Name,
|
|
"volume": obj.Shape.Volume,
|
|
"valid": obj.Shape.isValid()
|
|
}
|
|
""",
|
|
)
|
|
assert result["result"]["name"] == "TestBox"
|
|
# Volume should be 10 * 20 * 30 = 6000
|
|
assert abs(result["result"]["volume"] - 6000.0) < 0.01
|
|
assert result["result"]["valid"] is True
|
|
|
|
def test_create_cylinder(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
|
"""Test creating a Part::Cylinder primitive."""
|
|
result = execute_code(
|
|
xmlrpc_proxy,
|
|
"""
|
|
import FreeCAD
|
|
import Part
|
|
import math
|
|
|
|
doc = FreeCAD.ActiveDocument
|
|
cylinder = Part.makeCylinder(5, 20)
|
|
obj = doc.addObject("Part::Feature", "TestCylinder")
|
|
obj.Shape = cylinder
|
|
|
|
# Center viewport immediately after adding object
|
|
if FreeCAD.GuiUp:
|
|
import FreeCADGui
|
|
FreeCADGui.ActiveDocument.ActiveView.fitAll()
|
|
|
|
doc.recompute()
|
|
|
|
# Final viewport adjustment
|
|
if FreeCAD.GuiUp:
|
|
FreeCADGui.ActiveDocument.ActiveView.fitAll()
|
|
|
|
expected_volume = math.pi * 5**2 * 20
|
|
|
|
_result_ = {
|
|
"name": obj.Name,
|
|
"volume": obj.Shape.Volume,
|
|
"expected_volume": expected_volume,
|
|
"valid": obj.Shape.isValid()
|
|
}
|
|
""",
|
|
)
|
|
assert result["result"]["name"] == "TestCylinder"
|
|
# Volume should be π * r² * h = π * 25 * 20 ≈ 1570.8
|
|
assert (
|
|
abs(result["result"]["volume"] - result["result"]["expected_volume"]) < 0.1
|
|
)
|
|
assert result["result"]["valid"] is True
|
|
|
|
def test_create_sphere(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
|
"""Test creating a Part::Sphere primitive."""
|
|
result = execute_code(
|
|
xmlrpc_proxy,
|
|
"""
|
|
import FreeCAD
|
|
import Part
|
|
import math
|
|
|
|
doc = FreeCAD.ActiveDocument
|
|
sphere = Part.makeSphere(10)
|
|
obj = doc.addObject("Part::Feature", "TestSphere")
|
|
obj.Shape = sphere
|
|
|
|
# Center viewport immediately after adding object
|
|
if FreeCAD.GuiUp:
|
|
import FreeCADGui
|
|
FreeCADGui.ActiveDocument.ActiveView.fitAll()
|
|
|
|
doc.recompute()
|
|
|
|
# Final viewport adjustment
|
|
if FreeCAD.GuiUp:
|
|
FreeCADGui.ActiveDocument.ActiveView.fitAll()
|
|
|
|
expected_volume = (4/3) * math.pi * 10**3
|
|
|
|
_result_ = {
|
|
"name": obj.Name,
|
|
"volume": obj.Shape.Volume,
|
|
"expected_volume": expected_volume,
|
|
"valid": obj.Shape.isValid()
|
|
}
|
|
""",
|
|
)
|
|
assert result["result"]["name"] == "TestSphere"
|
|
# Volume should be (4/3) * π * r³ ≈ 4188.79
|
|
assert (
|
|
abs(result["result"]["volume"] - result["result"]["expected_volume"]) < 1.0
|
|
)
|
|
assert result["result"]["valid"] is True
|
|
|
|
def test_create_cone(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
|
"""Test creating a Part::Cone primitive."""
|
|
result = execute_code(
|
|
xmlrpc_proxy,
|
|
"""
|
|
import FreeCAD
|
|
import Part
|
|
|
|
doc = FreeCAD.ActiveDocument
|
|
cone = Part.makeCone(10, 0, 20) # Base radius=10, top radius=0, height=20
|
|
obj = doc.addObject("Part::Feature", "TestCone")
|
|
obj.Shape = cone
|
|
|
|
# Center viewport immediately after adding object
|
|
if FreeCAD.GuiUp:
|
|
import FreeCADGui
|
|
FreeCADGui.ActiveDocument.ActiveView.fitAll()
|
|
|
|
doc.recompute()
|
|
|
|
# Final viewport adjustment
|
|
if FreeCAD.GuiUp:
|
|
FreeCADGui.ActiveDocument.ActiveView.fitAll()
|
|
|
|
_result_ = {
|
|
"name": obj.Name,
|
|
"volume": obj.Shape.Volume,
|
|
"valid": obj.Shape.isValid()
|
|
}
|
|
""",
|
|
)
|
|
assert result["result"]["name"] == "TestCone"
|
|
# Volume of a cone = (1/3) * π * r² * h ≈ 2094.4
|
|
assert result["result"]["volume"] > 2000
|
|
assert result["result"]["valid"] is True
|
|
|
|
def test_create_torus(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
|
"""Test creating a Part::Torus primitive."""
|
|
result = execute_code(
|
|
xmlrpc_proxy,
|
|
"""
|
|
import FreeCAD
|
|
import Part
|
|
|
|
doc = FreeCAD.ActiveDocument
|
|
torus = Part.makeTorus(20, 5) # Major radius=20, minor radius=5
|
|
obj = doc.addObject("Part::Feature", "TestTorus")
|
|
obj.Shape = torus
|
|
|
|
# Center viewport immediately after adding object
|
|
if FreeCAD.GuiUp:
|
|
import FreeCADGui
|
|
FreeCADGui.ActiveDocument.ActiveView.fitAll()
|
|
|
|
doc.recompute()
|
|
|
|
# Final viewport adjustment
|
|
if FreeCAD.GuiUp:
|
|
FreeCADGui.ActiveDocument.ActiveView.fitAll()
|
|
|
|
_result_ = {
|
|
"name": obj.Name,
|
|
"volume": obj.Shape.Volume,
|
|
"valid": obj.Shape.isValid()
|
|
}
|
|
""",
|
|
)
|
|
assert result["result"]["name"] == "TestTorus"
|
|
assert result["result"]["volume"] > 0
|
|
assert result["result"]["valid"] is True
|
|
|
|
|
|
class TestBooleanOperations:
|
|
"""Tests for boolean operations."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def setup_document(
|
|
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, unique_suffix: str
|
|
) -> None:
|
|
"""Create a fresh document for each test."""
|
|
doc_name = f"BooleanTestDoc_{unique_suffix}"
|
|
execute_code(
|
|
xmlrpc_proxy,
|
|
f"""
|
|
import FreeCAD
|
|
doc_name = {doc_name!r}
|
|
if doc_name in FreeCAD.listDocuments():
|
|
FreeCAD.closeDocument(doc_name)
|
|
doc = FreeCAD.newDocument(doc_name)
|
|
|
|
# Set up initial view for GUI mode
|
|
if FreeCAD.GuiUp:
|
|
import FreeCADGui
|
|
FreeCADGui.ActiveDocument.ActiveView.viewIsometric()
|
|
|
|
_result_ = True
|
|
""",
|
|
)
|
|
|
|
def test_boolean_fuse(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
|
"""Test boolean fuse (union) operation."""
|
|
result = execute_code(
|
|
xmlrpc_proxy,
|
|
"""
|
|
import FreeCAD
|
|
import Part
|
|
|
|
doc = FreeCAD.ActiveDocument
|
|
|
|
# Create two overlapping boxes
|
|
box1 = Part.makeBox(10, 10, 10)
|
|
box2 = Part.makeBox(10, 10, 10, FreeCAD.Vector(5, 0, 0))
|
|
|
|
obj1 = doc.addObject("Part::Feature", "Box1")
|
|
obj1.Shape = box1
|
|
obj2 = doc.addObject("Part::Feature", "Box2")
|
|
obj2.Shape = box2
|
|
|
|
# Center viewport after adding initial objects
|
|
if FreeCAD.GuiUp:
|
|
import FreeCADGui
|
|
FreeCADGui.ActiveDocument.ActiveView.fitAll()
|
|
|
|
# Fuse them
|
|
fused = box1.fuse(box2)
|
|
obj_fused = doc.addObject("Part::Feature", "Fused")
|
|
obj_fused.Shape = fused
|
|
doc.recompute()
|
|
|
|
# Final viewport adjustment
|
|
if FreeCAD.GuiUp:
|
|
FreeCADGui.ActiveDocument.ActiveView.fitAll()
|
|
|
|
# Fused volume should be less than 2000 (two boxes) due to overlap
|
|
_result_ = {
|
|
"fused_volume": obj_fused.Shape.Volume,
|
|
"valid": obj_fused.Shape.isValid()
|
|
}
|
|
""",
|
|
)
|
|
# Two 10x10x10 boxes overlapping by 5mm = 2000 - 500 = 1500
|
|
assert result["result"]["fused_volume"] == pytest.approx(1500.0, rel=0.01)
|
|
assert result["result"]["valid"] is True
|
|
|
|
def test_boolean_cut(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
|
"""Test boolean cut (subtract) operation."""
|
|
result = execute_code(
|
|
xmlrpc_proxy,
|
|
"""
|
|
import FreeCAD
|
|
import Part
|
|
|
|
doc = FreeCAD.ActiveDocument
|
|
|
|
# Create a box and a cylinder to cut from it
|
|
box = Part.makeBox(20, 20, 20)
|
|
cylinder = Part.makeCylinder(5, 30, FreeCAD.Vector(10, 10, -5))
|
|
|
|
# Cut cylinder from box
|
|
cut = box.cut(cylinder)
|
|
obj_cut = doc.addObject("Part::Feature", "Cut")
|
|
obj_cut.Shape = cut
|
|
|
|
# Center viewport immediately after adding object
|
|
if FreeCAD.GuiUp:
|
|
import FreeCADGui
|
|
FreeCADGui.ActiveDocument.ActiveView.fitAll()
|
|
|
|
doc.recompute()
|
|
|
|
# Final viewport adjustment
|
|
if FreeCAD.GuiUp:
|
|
FreeCADGui.ActiveDocument.ActiveView.fitAll()
|
|
|
|
box_volume = 20 * 20 * 20
|
|
import math
|
|
cylinder_volume_in_box = math.pi * 5**2 * 20 # Only 20mm of cylinder is in box
|
|
|
|
_result_ = {
|
|
"cut_volume": obj_cut.Shape.Volume,
|
|
"expected_volume": box_volume - cylinder_volume_in_box,
|
|
"valid": obj_cut.Shape.isValid()
|
|
}
|
|
""",
|
|
)
|
|
# Volume should be box - cylinder intersection
|
|
assert (
|
|
abs(result["result"]["cut_volume"] - result["result"]["expected_volume"])
|
|
< 10
|
|
)
|
|
assert result["result"]["valid"] is True
|
|
|
|
|
|
class TestObjectManipulation:
|
|
"""Tests for object manipulation."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def setup_document(
|
|
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, unique_suffix: str
|
|
) -> None:
|
|
"""Create a fresh document with a test box."""
|
|
doc_name = f"ManipTestDoc_{unique_suffix}"
|
|
execute_code(
|
|
xmlrpc_proxy,
|
|
f"""
|
|
import FreeCAD
|
|
import Part
|
|
|
|
doc_name = {doc_name!r}
|
|
if doc_name in FreeCAD.listDocuments():
|
|
FreeCAD.closeDocument(doc_name)
|
|
doc = FreeCAD.newDocument(doc_name)
|
|
|
|
box = Part.makeBox(10, 10, 10)
|
|
obj = doc.addObject("Part::Feature", "ManipBox")
|
|
obj.Shape = box
|
|
doc.recompute()
|
|
|
|
# Center viewport for GUI mode recording
|
|
if FreeCAD.GuiUp:
|
|
import FreeCADGui
|
|
FreeCADGui.ActiveDocument.ActiveView.viewIsometric()
|
|
FreeCADGui.ActiveDocument.ActiveView.fitAll()
|
|
|
|
_result_ = True
|
|
""",
|
|
)
|
|
|
|
def test_set_placement_position(
|
|
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
|
|
) -> None:
|
|
"""Test setting object position."""
|
|
result = execute_code(
|
|
xmlrpc_proxy,
|
|
"""
|
|
import FreeCAD
|
|
|
|
doc = FreeCAD.ActiveDocument
|
|
obj = doc.getObject("ManipBox")
|
|
|
|
# Move the object
|
|
obj.Placement.Base = FreeCAD.Vector(100, 200, 300)
|
|
|
|
# Center viewport for GUI mode recording
|
|
if FreeCAD.GuiUp:
|
|
import FreeCADGui
|
|
FreeCADGui.ActiveDocument.ActiveView.viewIsometric()
|
|
FreeCADGui.ActiveDocument.ActiveView.fitAll()
|
|
|
|
_result_ = {
|
|
"x": obj.Placement.Base.x,
|
|
"y": obj.Placement.Base.y,
|
|
"z": obj.Placement.Base.z
|
|
}
|
|
""",
|
|
)
|
|
assert result["result"]["x"] == 100.0
|
|
assert result["result"]["y"] == 200.0
|
|
assert result["result"]["z"] == 300.0
|
|
|
|
def test_set_placement_rotation(
|
|
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
|
|
) -> None:
|
|
"""Test setting object rotation."""
|
|
result = execute_code(
|
|
xmlrpc_proxy,
|
|
"""
|
|
import FreeCAD
|
|
|
|
doc = FreeCAD.ActiveDocument
|
|
obj = doc.getObject("ManipBox")
|
|
|
|
# Rotate the object 45 degrees around Z axis
|
|
obj.Placement.Rotation = FreeCAD.Rotation(FreeCAD.Vector(0, 0, 1), 45)
|
|
doc.recompute()
|
|
|
|
# Center viewport for GUI mode recording
|
|
if FreeCAD.GuiUp:
|
|
import FreeCADGui
|
|
FreeCADGui.ActiveDocument.ActiveView.viewIsometric()
|
|
FreeCADGui.ActiveDocument.ActiveView.fitAll()
|
|
|
|
euler = obj.Placement.Rotation.toEuler()
|
|
_result_ = {
|
|
"rotation_z": euler[0], # Yaw
|
|
"valid": obj.Shape.isValid()
|
|
}
|
|
""",
|
|
)
|
|
# Check rotation is approximately 45 degrees
|
|
assert abs(result["result"]["rotation_z"] - 45.0) < 0.1
|
|
assert result["result"]["valid"] is True
|
|
|
|
|
|
class TestExportOperations:
|
|
"""Tests for export functionality."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def setup_document(
|
|
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str, unique_suffix: str
|
|
) -> None:
|
|
"""Create a document with objects for export tests."""
|
|
doc_name = f"ExportTestDoc_{unique_suffix}"
|
|
execute_code(
|
|
xmlrpc_proxy,
|
|
f"""
|
|
import FreeCAD
|
|
import Part
|
|
|
|
doc_name = {doc_name!r}
|
|
if doc_name in FreeCAD.listDocuments():
|
|
FreeCAD.closeDocument(doc_name)
|
|
doc = FreeCAD.newDocument(doc_name)
|
|
|
|
# Create a simple box
|
|
box = Part.makeBox(10, 10, 10)
|
|
obj = doc.addObject("Part::Feature", "ExportBox")
|
|
obj.Shape = box
|
|
doc.recompute()
|
|
|
|
# Center viewport for GUI mode recording
|
|
if FreeCAD.GuiUp:
|
|
import FreeCADGui
|
|
FreeCADGui.ActiveDocument.ActiveView.viewIsometric()
|
|
FreeCADGui.ActiveDocument.ActiveView.fitAll()
|
|
|
|
_result_ = True
|
|
""",
|
|
)
|
|
self.temp_dir = temp_dir
|
|
|
|
def test_export_step(
|
|
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str
|
|
) -> None:
|
|
"""Test exporting to STEP format."""
|
|
step_path = Path(temp_dir) / "test_export.step"
|
|
step_path_str = str(step_path)
|
|
|
|
result = execute_code(
|
|
xmlrpc_proxy,
|
|
f"""
|
|
import FreeCAD
|
|
|
|
doc = FreeCAD.ActiveDocument
|
|
obj = doc.getObject("ExportBox")
|
|
obj.Shape.exportStep({step_path_str!r})
|
|
|
|
import os
|
|
_result_ = {{"exported": os.path.exists({step_path_str!r})}}
|
|
""",
|
|
)
|
|
assert result["result"]["exported"] is True
|
|
assert step_path.exists()
|
|
|
|
def test_save_fcstd(
|
|
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str
|
|
) -> None:
|
|
"""Test saving as FreeCAD native format."""
|
|
fcstd_path = Path(temp_dir) / "test_save.FCStd"
|
|
fcstd_path_str = str(fcstd_path)
|
|
|
|
result = execute_code(
|
|
xmlrpc_proxy,
|
|
f"""
|
|
import FreeCAD
|
|
|
|
doc = FreeCAD.ActiveDocument
|
|
doc.saveAs({fcstd_path_str!r})
|
|
|
|
import os
|
|
_result_ = {{
|
|
"saved": os.path.exists({fcstd_path_str!r}),
|
|
"path": {fcstd_path_str!r}
|
|
}}
|
|
""",
|
|
)
|
|
assert result["result"]["saved"] is True
|
|
assert fcstd_path.exists()
|
|
|
|
|
|
@requires_headless
|
|
class TestGUIFeaturesInHeadless:
|
|
"""Tests to verify GUI features fail gracefully in headless mode.
|
|
|
|
These tests are skipped when FreeCAD is running in GUI mode.
|
|
"""
|
|
|
|
def test_gui_features_not_available(
|
|
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
|
|
) -> None:
|
|
"""Test that GUI-only features return appropriate errors."""
|
|
result = execute_code(
|
|
xmlrpc_proxy,
|
|
"""
|
|
import FreeCAD
|
|
|
|
gui_available = FreeCAD.GuiUp
|
|
|
|
if not gui_available:
|
|
_result_ = {
|
|
"success": False,
|
|
"error": "GUI not available in headless mode",
|
|
"gui_up": False
|
|
}
|
|
else:
|
|
_result_ = {
|
|
"success": True,
|
|
"gui_up": True
|
|
}
|
|
""",
|
|
)
|
|
# In headless mode, GUI should not be available
|
|
assert result["result"]["gui_up"] is False
|
|
|
|
|
|
class TestComplexWorkflow:
|
|
"""Tests for complex multi-step workflows."""
|
|
|
|
def test_parametric_modeling_workflow(
|
|
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, unique_suffix: str
|
|
) -> None:
|
|
"""Test a complete parametric modeling workflow."""
|
|
doc_name = f"WorkflowTestDoc_{unique_suffix}"
|
|
result = execute_code(
|
|
xmlrpc_proxy,
|
|
f"""
|
|
import FreeCAD
|
|
import Part
|
|
|
|
# Create document
|
|
doc_name = {doc_name!r}
|
|
if doc_name in FreeCAD.listDocuments():
|
|
FreeCAD.closeDocument(doc_name)
|
|
doc = FreeCAD.newDocument(doc_name)
|
|
|
|
# Step 1: Create base box
|
|
base_box = Part.makeBox(50, 50, 10)
|
|
base_obj = doc.addObject("Part::Feature", "Base")
|
|
base_obj.Shape = base_box
|
|
|
|
# Step 2: Create cylinder to cut
|
|
cylinder = Part.makeCylinder(5, 20, FreeCAD.Vector(25, 25, -5))
|
|
|
|
# Step 3: Cut hole in base
|
|
result_shape = base_box.cut(cylinder)
|
|
final_obj = doc.addObject("Part::Feature", "BaseWithHole")
|
|
final_obj.Shape = result_shape
|
|
|
|
# Step 4: Add fillet (rounded edges) - using Part API
|
|
# Select edges and apply fillet
|
|
try:
|
|
filleted = final_obj.Shape.makeFillet(2, final_obj.Shape.Edges[:4])
|
|
fillet_obj = doc.addObject("Part::Feature", "Filleted")
|
|
fillet_obj.Shape = filleted
|
|
fillet_success = True
|
|
except Exception:
|
|
fillet_success = False
|
|
|
|
# Center viewport immediately after adding objects (before recompute)
|
|
if FreeCAD.GuiUp:
|
|
import FreeCADGui
|
|
FreeCADGui.ActiveDocument.ActiveView.viewIsometric()
|
|
FreeCADGui.ActiveDocument.ActiveView.fitAll()
|
|
|
|
doc.recompute()
|
|
|
|
# Final viewport adjustment after recompute
|
|
if FreeCAD.GuiUp:
|
|
FreeCADGui.ActiveDocument.ActiveView.fitAll()
|
|
|
|
_result_ = {{
|
|
"objects": [obj.Name for obj in doc.Objects],
|
|
"final_volume": final_obj.Shape.Volume,
|
|
"final_valid": final_obj.Shape.isValid(),
|
|
"fillet_success": fillet_success
|
|
}}
|
|
""",
|
|
)
|
|
assert "Base" in result["result"]["objects"]
|
|
assert "BaseWithHole" in result["result"]["objects"]
|
|
assert result["result"]["final_valid"] is True
|
|
# Volume should be base - cylinder hole
|
|
assert result["result"]["final_volume"] > 0
|