* ci: add caching and fix workflow failures - Add UV package caching to all workflows for faster dependency installation - Add pre-commit hook caching with OS-specific cache keys - Skip no-commit-to-branch hook in CI (fails on main branch) - Remove broken apt cache action (doesn't work with PPAs) - Simplify FreeCAD command detection (PPA installs to standard PATH) - Add FreeCAD Python version logging for debugging * ci: Add code Rabbit configuration file * ci: fix issues in CI workflows * ci: add uv.lock * ci: tweaks * ci: Fix errors and add UUID generation and checking * ci: Use the GitHub FreeCAD release latest stables * ci: skip macro test for now, due to headless mode * feat: Add a multi export macro * ci: Fix tests * ci: fix docker build workflow * ci: skip trufflehog in GitHub Actions due to wasm panic bug TruffleHog has a known wasm/go-re2 panic bug that causes failures in GitHub Actions environment. The hook still runs locally during development for secrets detection. - Add trufflehog to SKIP env var in pre-commit.yaml - Update trufflehog to v3.88.7 (latest) - Add reference to upstream issue #3321 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test: use boolean holes instead of PartDesign::Hole in CI PartDesign::Hole has a CADKernelError bug in FreeCAD AppImage headless mode on Linux (used in GitHub Actions) where it fails with "Cannot make face from profile". The SmartCutter class already supports boolean holes as an alternative. Changes: - Modify SmartCutter.execute() to use boolean holes by default - Update all test assertions for Part::Feature output type - Update test docstrings and class descriptions - Remove PartDesign-specific checks (Group, Sketcher::SketchObject) - Update workflow comment explaining the CI limitation Boolean holes work reliably in both GUI and headless mode across all FreeCAD configurations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * ci: clean up GitHub Actions workflows * ci: Dependabot improvements * ci: add CodeQL scanning workflow * ci: AI review suggested improvements * ci: add coderabbit updates for intentional decisions --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
627 lines
17 KiB
Python
627 lines
17 KiB
Python
"""Integration tests for FreeCAD MCP headless mode.
|
|
|
|
These tests verify that the MCP bridge works correctly when FreeCAD is running
|
|
in headless mode (without GUI). They test object creation, manipulation, and
|
|
export functionality.
|
|
|
|
Note: These tests require a running FreeCAD headless server.
|
|
Start it with: just run-headless
|
|
|
|
To run these tests:
|
|
pytest tests/integration/test_headless_mode.py -v
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import pytest
|
|
|
|
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
|
|
|
|
|
|
@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
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
def test_headless_mode_detected(
|
|
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
|
|
) -> None:
|
|
"""Test that FreeCAD is running in headless mode (GuiUp=False)."""
|
|
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) -> None:
|
|
"""Test creating a new document."""
|
|
result = execute_code(
|
|
xmlrpc_proxy,
|
|
"""
|
|
import FreeCAD
|
|
doc = FreeCAD.newDocument("TestDoc")
|
|
_result_ = {"name": doc.Name, "object_count": len(doc.Objects)}
|
|
""",
|
|
)
|
|
assert result["result"]["name"] == "TestDoc"
|
|
assert result["result"]["object_count"] == 0
|
|
|
|
def test_list_documents(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
|
"""Test listing open documents."""
|
|
# First create a document
|
|
execute_code(
|
|
xmlrpc_proxy,
|
|
"""
|
|
import FreeCAD
|
|
if not FreeCAD.listDocuments():
|
|
FreeCAD.newDocument("ListTestDoc")
|
|
_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) -> None:
|
|
"""Test closing a document."""
|
|
# Create a document to close
|
|
execute_code(
|
|
xmlrpc_proxy,
|
|
"""
|
|
import FreeCAD
|
|
doc = FreeCAD.newDocument("ToClose")
|
|
_result_ = True
|
|
""",
|
|
)
|
|
|
|
result = execute_code(
|
|
xmlrpc_proxy,
|
|
"""
|
|
import FreeCAD
|
|
FreeCAD.closeDocument("ToClose")
|
|
_result_ = {"closed": "ToClose" not in FreeCAD.listDocuments()}
|
|
""",
|
|
)
|
|
assert result["result"]["closed"] is True
|
|
|
|
|
|
class TestPrimitiveCreation:
|
|
"""Tests for creating primitive shapes in headless mode."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
|
"""Create a fresh document for each test."""
|
|
execute_code(
|
|
xmlrpc_proxy,
|
|
"""
|
|
import FreeCAD
|
|
# Close any existing PrimitiveTestDoc
|
|
if "PrimitiveTestDoc" in FreeCAD.listDocuments():
|
|
FreeCAD.closeDocument("PrimitiveTestDoc")
|
|
doc = FreeCAD.newDocument("PrimitiveTestDoc")
|
|
_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
|
|
doc.recompute()
|
|
|
|
_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
|
|
doc.recompute()
|
|
|
|
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
|
|
doc.recompute()
|
|
|
|
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
|
|
doc.recompute()
|
|
|
|
_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
|
|
doc.recompute()
|
|
|
|
_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 in headless mode."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
|
"""Create a fresh document for each test."""
|
|
execute_code(
|
|
xmlrpc_proxy,
|
|
"""
|
|
import FreeCAD
|
|
if "BooleanTestDoc" in FreeCAD.listDocuments():
|
|
FreeCAD.closeDocument("BooleanTestDoc")
|
|
doc = FreeCAD.newDocument("BooleanTestDoc")
|
|
_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
|
|
|
|
# Fuse them
|
|
fused = box1.fuse(box2)
|
|
obj_fused = doc.addObject("Part::Feature", "Fused")
|
|
obj_fused.Shape = fused
|
|
doc.recompute()
|
|
|
|
# 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
|
|
doc.recompute()
|
|
|
|
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 in headless mode."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
|
|
"""Create a fresh document with a test box."""
|
|
execute_code(
|
|
xmlrpc_proxy,
|
|
"""
|
|
import FreeCAD
|
|
import Part
|
|
|
|
if "ManipTestDoc" in FreeCAD.listDocuments():
|
|
FreeCAD.closeDocument("ManipTestDoc")
|
|
doc = FreeCAD.newDocument("ManipTestDoc")
|
|
|
|
box = Part.makeBox(10, 10, 10)
|
|
obj = doc.addObject("Part::Feature", "ManipBox")
|
|
obj.Shape = box
|
|
doc.recompute()
|
|
_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)
|
|
|
|
_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()
|
|
|
|
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 in headless mode."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def setup_document(
|
|
self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str
|
|
) -> None:
|
|
"""Create a document with objects for export tests."""
|
|
execute_code(
|
|
xmlrpc_proxy,
|
|
"""
|
|
import FreeCAD
|
|
import Part
|
|
|
|
if "ExportTestDoc" in FreeCAD.listDocuments():
|
|
FreeCAD.closeDocument("ExportTestDoc")
|
|
doc = FreeCAD.newDocument("ExportTestDoc")
|
|
|
|
# Create a simple box
|
|
box = Part.makeBox(10, 10, 10)
|
|
obj = doc.addObject("Part::Feature", "ExportBox")
|
|
obj.Shape = box
|
|
doc.recompute()
|
|
|
|
_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()
|
|
|
|
|
|
class TestGUIFeaturesInHeadless:
|
|
"""Tests to verify GUI features fail gracefully in headless 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 in headless mode."""
|
|
|
|
def test_parametric_modeling_workflow(
|
|
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
|
|
) -> None:
|
|
"""Test a complete parametric modeling workflow."""
|
|
result = execute_code(
|
|
xmlrpc_proxy,
|
|
"""
|
|
import FreeCAD
|
|
import Part
|
|
|
|
# Create document
|
|
if "WorkflowTestDoc" in FreeCAD.listDocuments():
|
|
FreeCAD.closeDocument("WorkflowTestDoc")
|
|
doc = FreeCAD.newDocument("WorkflowTestDoc")
|
|
|
|
# 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
|
|
|
|
doc.recompute()
|
|
|
|
_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
|