Files
freecad-robust-mcp-fc111/tests/integration/test_cut_object_for_magnets.py
T

1029 lines
33 KiB
Python

"""Integration tests for the CutObjectForMagnets macro.
These tests verify the SmartCutter class functionality including:
- Cutting solid objects with PartDesign::Hole features
- Cutting hollow objects with PartDesign::Hole features
- Fallback boolean hole creation method
Note: These tests require a running FreeCAD MCP bridge.
Start it with: just run-gui or just run-headless
To run these tests:
pytest tests/integration/test_cut_object_for_magnets.py -v
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import pytest
if TYPE_CHECKING:
import xmlrpc.client
# Mark all tests in this module as integration tests
pytestmark = pytest.mark.integration
# Note: xmlrpc_proxy fixture is defined in conftest.py
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
# The SmartCutter class code embedded for testing
# This avoids issues with importing the macro file directly
SMART_CUTTER_CODE = '''
import FreeCAD as App
import Part
class HolePlacementError(Exception):
"""Raised when hole placement fails."""
pass
class SmartCutter:
"""Handles cutting objects and placing magnet holes with collision detection."""
def __init__(self, obj, params: dict):
"""Initialize the cutter."""
self.obj = obj
self.params = params
self.shape = obj.Shape
def get_cut_plane_normal_and_point(self):
"""Get plane normal vector and point based on selected plane."""
plane_type = self.params.get("plane_type", "Preset Plane")
plane = self.params["plane"]
offset = self.params["offset"]
if plane == "XY":
normal = App.Vector(0, 0, 1)
point = App.Vector(0, 0, offset)
elif plane == "XZ":
normal = App.Vector(0, 1, 0)
point = App.Vector(0, offset, 0)
elif plane == "YZ":
normal = App.Vector(1, 0, 0)
point = App.Vector(offset, 0, 0)
else:
normal = App.Vector(0, 0, 1)
point = App.Vector(0, 0, offset)
return normal, point
def cut_object(self):
"""Cut the object along the specified plane."""
normal, point = self.get_cut_plane_normal_and_point()
bbox = self.shape.BoundBox
size = max(bbox.XLength, bbox.YLength, bbox.ZLength) * 3
half = size / 2
box = Part.makeBox(size, size, size, App.Vector(-half, -half, 0))
z_axis = App.Vector(0, 0, 1)
rotation = App.Rotation(z_axis, normal)
box = box.transformed(App.Matrix(rotation.toMatrix()))
box.translate(point)
try:
bottom_part = self.shape.cut(box)
top_part = self.shape.common(box)
return bottom_part, top_part
except Exception as e:
raise HolePlacementError(f"Failed to cut object: {e!s}") from e
def get_cut_face_center(self, part, normal):
"""Find the center of the cut face on a part."""
_, cut_point = self.get_cut_plane_normal_and_point()
best_face = None
best_dist = float("inf")
for face in part.Faces:
face_normal = face.normalAt(0, 0)
dot = abs(face_normal.dot(normal))
if dot > 0.99:
face_center = face.CenterOfMass
dist_along_normal = abs((face_center - cut_point).dot(normal))
if dist_along_normal < best_dist:
best_dist = dist_along_normal
best_face = face
if best_face is not None:
return best_face.CenterOfMass
return None
def is_hole_safe(self, center, direction, part, clearance=None):
"""Check if a hole at this position would penetrate the outer surface."""
diameter = self.params["diameter"]
depth = self.params["depth"]
if clearance is None:
clearance = self.params["clearance_min"]
dir_normalized = App.Vector(direction).normalize()
radius_check = (diameter / 2) + clearance
start_offset = 0.5
start_pos = center + (dir_normalized * start_offset)
test_length = depth - start_offset
if test_length <= 0:
return True
test_cylinder = Part.makeCylinder(
radius_check, test_length, start_pos, dir_normalized
)
try:
intersection = part.common(test_cylinder)
cylinder_vol = test_cylinder.Volume
intersection_vol = intersection.Volume
if intersection_vol < cylinder_vol * 0.95:
return False
return True
except Exception:
return False
def generate_hole_positions(self, cut_face_center, cut_face):
"""Generate hole positions evenly distributed along the perimeter."""
hole_count = self.params["hole_count"]
clearance = self.params["clearance_preferred"]
diameter = self.params["diameter"]
wires = cut_face.Wires
if not wires:
return [], None, 0, []
outer_wire = max(wires, key=lambda w: w.Length)
perimeter_length = outer_wire.Length
inset = clearance + (diameter / 2)
normal, _ = self.get_cut_plane_normal_and_point()
normal = App.Vector(normal).normalize()
if hole_count < 1:
return [], outer_wire, perimeter_length, []
spacing = perimeter_length / hole_count
positions = []
original_params = []
for i in range(hole_count):
param = (i * spacing) + (spacing / 2)
if param >= perimeter_length:
param = param - perimeter_length
edge_point = self._get_point_at_length(outer_wire, param)
if edge_point is None:
continue
inset_point = self._get_inset_point(edge_point, cut_face, normal, inset)
if inset_point:
positions.append(inset_point)
original_params.append(param)
return positions, outer_wire, perimeter_length, original_params
def _get_point_at_length(self, wire, length):
"""Get a point on the wire at a specific length along it."""
cumulative_length = 0.0
for edge in wire.Edges:
edge_length = edge.Length
if cumulative_length + edge_length >= length:
remaining = length - cumulative_length
param = remaining / edge_length if edge_length > 0 else 0
first_param = edge.FirstParameter
last_param = edge.LastParameter
edge_param = first_param + param * (last_param - first_param)
try:
point = edge.valueAt(edge_param)
return App.Vector(point)
except Exception:
return None
cumulative_length += edge_length
return None
def _get_inset_point(self, edge_point, cut_face, normal, inset):
"""Get a point that is inset from the edge toward the face interior."""
face_center = cut_face.CenterOfMass
to_center = face_center - edge_point
to_center = to_center - normal * (to_center.dot(normal))
if to_center.Length < 0.001:
return None
to_center_normalized = App.Vector(to_center).normalize()
inset_point = edge_point + (to_center_normalized * inset)
try:
dist_info = cut_face.distToShape(Part.Vertex(inset_point))
dist = dist_info[0]
if dist < 0.5:
closest_on_face = dist_info[1][0][0]
return App.Vector(closest_on_face)
else:
dist_info = cut_face.distToShape(Part.Vertex(inset_point))
return App.Vector(dist_info[1][0][0])
except Exception:
return None
def _check_hole_overlap(self, positions, new_pos):
"""Check if a new hole position would overlap with existing holes."""
diameter = self.params["diameter"]
min_distance = diameter * 2
for existing_pos in positions:
dist = (new_pos - existing_pos).Length
if dist < min_distance:
return False
return True
def execute(self, progress_callback=None):
"""Execute the complete cutting and hole placement operation."""
bottom_shape, top_shape = self.cut_object()
normal, _ = self.get_cut_plane_normal_and_point()
bottom_face_center = self.get_cut_face_center(bottom_shape, -normal)
top_face_center = self.get_cut_face_center(top_shape, normal)
if not bottom_face_center or not top_face_center:
raise HolePlacementError("Could not find cut faces")
bottom_cut_face = None
for face in bottom_shape.Faces:
if face.CenterOfMass.distanceToPoint(bottom_face_center) < 0.1:
bottom_cut_face = face
break
if not bottom_cut_face:
raise HolePlacementError("Could not find bottom cut face")
top_cut_face = None
for face in top_shape.Faces:
if face.CenterOfMass.distanceToPoint(top_face_center) < 0.1:
top_cut_face = face
break
if not top_cut_face:
raise HolePlacementError("Could not find top cut face")
initial_positions, outer_wire, perimeter_length, original_params = (
self.generate_hole_positions(bottom_face_center, bottom_cut_face)
)
if not initial_positions:
raise HolePlacementError("No valid hole positions found")
clearance_preferred = self.params["clearance_preferred"]
validated_positions = []
for idx, pos in enumerate(initial_positions):
bottom_safe = self.is_hole_safe(pos, -normal, bottom_shape, clearance_preferred)
top_safe = self.is_hole_safe(pos, normal, top_shape, clearance_preferred)
if bottom_safe and top_safe:
if self._check_hole_overlap(validated_positions, pos):
validated_positions.append(pos)
if not validated_positions:
raise HolePlacementError("No valid hole positions found after validation")
# Create PartDesign::Body objects from shapes
bottom_body = self._create_body_from_shape(
bottom_shape, f"{self.obj.Label}_Bottom"
)
top_body = self._create_body_from_shape(top_shape, f"{self.obj.Label}_Top")
# Find cut face names on the new bodies
bottom_face_name = self._find_cut_face_name(bottom_body, -normal)
top_face_name = self._find_cut_face_name(top_body, normal)
# Create hole sketches with points at validated positions
bottom_sketch = self._create_hole_sketch(
bottom_body, bottom_face_name, validated_positions
)
top_sketch = self._create_hole_sketch(
top_body, top_face_name, validated_positions
)
# Create PartDesign::Hole features
self._create_hole_feature(
bottom_body, bottom_sketch, self.params["diameter"], self.params["depth"]
)
self._create_hole_feature(
top_body, top_sketch, self.params["diameter"], self.params["depth"]
)
return bottom_body, top_body
def _create_body_from_shape(self, shape, name):
"""Create a PartDesign::Body containing the given shape."""
doc = App.ActiveDocument
base_feature_name = f"{name}_Base"
feature = doc.addObject("Part::Feature", base_feature_name)
feature.Shape = shape
body = doc.addObject("PartDesign::Body", name)
body.BaseFeature = feature
if hasattr(feature, "ViewObject") and feature.ViewObject:
feature.ViewObject.Visibility = False
doc.recompute()
return body
def _find_cut_face_name(self, body, normal):
"""Find the name of the cut face on a PartDesign::Body's Tip feature."""
tip_feature = body.Tip
if tip_feature is None:
raise HolePlacementError(f"Body {body.Label} has no Tip feature")
shape = tip_feature.Shape
target_normal = App.Vector(normal).normalize()
best_face_idx = None
best_dot = -1
for i, face in enumerate(shape.Faces):
try:
face_normal = face.normalAt(0.5, 0.5)
dot = face_normal.dot(target_normal)
if dot > best_dot:
best_dot = dot
best_face_idx = i
except Exception:
continue
if best_face_idx is not None and best_dot > 0.99:
return f"Face{best_face_idx + 1}"
raise HolePlacementError(
f"Could not find cut face on body {body.Label}. "
f"Best match had dot product {best_dot:.3f}"
)
def _world_to_sketch_coords(self, world_pos, sketch):
"""Transform world coordinates to sketch-local 2D coordinates."""
placement = sketch.Placement
inv_placement = placement.inverse()
local_pos = inv_placement.multVec(world_pos)
return App.Vector(local_pos.x, local_pos.y, 0)
def _create_hole_sketch(self, body, cut_face_name, positions):
"""Create a sketch with points at hole center positions."""
sketch = body.newObject("Sketcher::SketchObject", "HoleCenters")
tip_feature = body.Tip
if tip_feature is None:
raise HolePlacementError(f"Body {body.Label} has no Tip feature")
sketch.AttachmentSupport = [(tip_feature, cut_face_name)]
sketch.MapMode = "FlatFace"
App.ActiveDocument.recompute()
for pos in positions:
local_pos = self._world_to_sketch_coords(pos, sketch)
sketch.addGeometry(
Part.Point(App.Vector(local_pos.x, local_pos.y, 0)),
False,
)
App.ActiveDocument.recompute()
return sketch
def _create_hole_feature(self, body, sketch, diameter, depth):
"""Create a PartDesign::Hole feature from a sketch with point geometry."""
hole = body.newObject("PartDesign::Hole", "MagnetHoles")
hole.Profile = sketch
hole.Diameter = diameter
hole.Depth = depth
hole.DepthType = "Dimension"
hole.Threaded = False
hole.HoleCutType = "None"
App.ActiveDocument.recompute()
return hole
def _create_holes_boolean(self, part, direction, positions):
"""Create holes using boolean operations (fallback method)."""
diameter = self.params["diameter"]
depth = self.params["depth"]
dir_normalized = App.Vector(direction).normalize()
result = part
holes_created = 0
for pos in positions:
offset = 0.1
start_pos = pos - (dir_normalized * offset)
hole_length = depth + offset
try:
hole = Part.makeCylinder(
diameter / 2, hole_length, start_pos, dir_normalized
)
result = result.cut(hole)
holes_created += 1
except Exception:
pass
return result
'''
class TestCutSolidObjectPartDesignHole:
"""Tests for cutting solid objects with PartDesign::Hole features."""
@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 "CutMacroTestDoc" in FreeCAD.listDocuments():
FreeCAD.closeDocument("CutMacroTestDoc")
doc = FreeCAD.newDocument("CutMacroTestDoc")
_result_ = True
""",
)
def test_cut_solid_box_with_partdesign_holes(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
) -> None:
"""Test cutting a solid box and creating PartDesign::Hole features."""
result = execute_code(
xmlrpc_proxy,
SMART_CUTTER_CODE
+ """
doc = App.ActiveDocument
# Create a solid box: 50x50x40mm
box = Part.makeBox(50, 50, 40)
box_obj = doc.addObject("Part::Feature", "TestBox")
box_obj.Shape = box
doc.recompute()
# Create SmartCutter with parameters
params = {
"plane_type": "Preset Plane",
"plane": "XY",
"offset": 20.0, # Cut in the middle
"diameter": 6.0,
"depth": 3.0,
"hole_count": 4,
"clearance_preferred": 2.0,
"clearance_min": 0.5,
}
cutter = SmartCutter(box_obj, params)
# Execute the cut
bottom_body, top_body = cutter.execute()
doc.recompute()
# Verify results
_result_ = {
"success": True,
"bottom_body_type": bottom_body.TypeId,
"top_body_type": top_body.TypeId,
"bottom_body_name": bottom_body.Label,
"top_body_name": top_body.Label,
"bottom_has_tip": bottom_body.Tip is not None,
"top_has_tip": top_body.Tip is not None,
"bottom_volume": bottom_body.Shape.Volume,
"top_volume": top_body.Shape.Volume,
"bottom_valid": bottom_body.Shape.isValid(),
"top_valid": top_body.Shape.isValid(),
}
# Check for PartDesign::Hole features
for obj in bottom_body.Group:
if obj.TypeId == "PartDesign::Hole":
_result_["bottom_has_hole_feature"] = True
_result_["bottom_hole_diameter"] = obj.Diameter.Value
_result_["bottom_hole_depth"] = obj.Depth.Value
break
else:
_result_["bottom_has_hole_feature"] = False
for obj in top_body.Group:
if obj.TypeId == "PartDesign::Hole":
_result_["top_has_hole_feature"] = True
_result_["top_hole_diameter"] = obj.Diameter.Value
_result_["top_hole_depth"] = obj.Depth.Value
break
else:
_result_["top_has_hole_feature"] = False
""",
)
assert result["result"]["success"] is True
assert result["result"]["bottom_body_type"] == "PartDesign::Body"
assert result["result"]["top_body_type"] == "PartDesign::Body"
assert "Bottom" in result["result"]["bottom_body_name"]
assert "Top" in result["result"]["top_body_name"]
assert result["result"]["bottom_has_tip"] is True
assert result["result"]["top_has_tip"] is True
assert result["result"]["bottom_valid"] is True
assert result["result"]["top_valid"] is True
# Check PartDesign::Hole features exist
assert result["result"]["bottom_has_hole_feature"] is True
assert result["result"]["top_has_hole_feature"] is True
# Check hole parameters
assert result["result"]["bottom_hole_diameter"] == pytest.approx(6.0, abs=0.1)
assert result["result"]["bottom_hole_depth"] == pytest.approx(3.0, abs=0.1)
assert result["result"]["top_hole_diameter"] == pytest.approx(6.0, abs=0.1)
assert result["result"]["top_hole_depth"] == pytest.approx(3.0, abs=0.1)
# Volumes should be roughly half (minus hole volume)
assert result["result"]["bottom_volume"] > 0
assert result["result"]["top_volume"] > 0
class TestCutHollowObjectPartDesignHole:
"""Tests for cutting hollow objects with PartDesign::Hole features."""
@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 "CutHollowTestDoc" in FreeCAD.listDocuments():
FreeCAD.closeDocument("CutHollowTestDoc")
doc = FreeCAD.newDocument("CutHollowTestDoc")
_result_ = True
""",
)
def test_cut_hollow_cylinder_with_partdesign_holes(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
) -> None:
"""Test cutting a hollow cylinder (vase shape) with PartDesign::Hole features."""
result = execute_code(
xmlrpc_proxy,
SMART_CUTTER_CODE
+ """
import math
doc = App.ActiveDocument
# Create a hollow vase-shaped cylinder
# Outer cylinder: radius 20mm, height 60mm
# Inner cylinder: radius 13mm (wall thickness 7mm), height 55mm (5mm bottom)
outer_radius = 20.0
inner_radius = 13.0
height = 60.0
bottom_thickness = 5.0
# Create outer cylinder
outer = Part.makeCylinder(outer_radius, height)
# Create inner cylinder (starting above bottom)
inner = Part.makeCylinder(inner_radius, height - bottom_thickness, App.Vector(0, 0, bottom_thickness))
# Cut inner from outer to make hollow vase
vase_shape = outer.cut(inner)
vase_obj = doc.addObject("Part::Feature", "TestVase")
vase_obj.Shape = vase_shape
doc.recompute()
# Create SmartCutter with parameters
params = {
"plane_type": "Preset Plane",
"plane": "XY",
"offset": 30.0, # Cut at 30mm height (middle of vase)
"diameter": 3.0, # Smaller holes for thin walls
"depth": 3.0,
"hole_count": 6,
"clearance_preferred": 2.0,
"clearance_min": 0.5,
}
cutter = SmartCutter(vase_obj, params)
# Execute the cut
bottom_body, top_body = cutter.execute()
doc.recompute()
# Verify results
_result_ = {
"success": True,
"bottom_body_type": bottom_body.TypeId,
"top_body_type": top_body.TypeId,
"bottom_valid": bottom_body.Shape.isValid(),
"top_valid": top_body.Shape.isValid(),
"bottom_volume": bottom_body.Shape.Volume,
"top_volume": top_body.Shape.Volume,
}
# Check for PartDesign::Hole features
hole_count_bottom = 0
hole_count_top = 0
for obj in bottom_body.Group:
if obj.TypeId == "PartDesign::Hole":
_result_["bottom_has_hole_feature"] = True
_result_["bottom_hole_diameter"] = obj.Diameter.Value
hole_count_bottom += 1
for obj in top_body.Group:
if obj.TypeId == "PartDesign::Hole":
_result_["top_has_hole_feature"] = True
_result_["top_hole_diameter"] = obj.Diameter.Value
hole_count_top += 1
_result_["bottom_hole_feature_count"] = hole_count_bottom
_result_["top_hole_feature_count"] = hole_count_top
# Default to False if no holes found
if "bottom_has_hole_feature" not in _result_:
_result_["bottom_has_hole_feature"] = False
if "top_has_hole_feature" not in _result_:
_result_["top_has_hole_feature"] = False
""",
)
assert result["result"]["success"] is True
assert result["result"]["bottom_body_type"] == "PartDesign::Body"
assert result["result"]["top_body_type"] == "PartDesign::Body"
assert result["result"]["bottom_valid"] is True
assert result["result"]["top_valid"] is True
# Check PartDesign::Hole features exist
assert result["result"]["bottom_has_hole_feature"] is True
assert result["result"]["top_has_hole_feature"] is True
# Each body should have one Hole feature (covering all points in sketch)
assert result["result"]["bottom_hole_feature_count"] >= 1
assert result["result"]["top_hole_feature_count"] >= 1
# Verify hole diameter
assert result["result"]["bottom_hole_diameter"] == pytest.approx(3.0, abs=0.1)
assert result["result"]["top_hole_diameter"] == pytest.approx(3.0, abs=0.1)
class TestBooleanHoleFallback:
"""Tests for the fallback boolean hole creation method."""
@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 "BooleanHoleTestDoc" in FreeCAD.listDocuments():
FreeCAD.closeDocument("BooleanHoleTestDoc")
doc = FreeCAD.newDocument("BooleanHoleTestDoc")
_result_ = True
""",
)
def test_boolean_hole_creation_fallback(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
) -> None:
"""Test the _create_holes_boolean fallback method directly."""
result = execute_code(
xmlrpc_proxy,
SMART_CUTTER_CODE
+ """
doc = App.ActiveDocument
# Create a solid box: 50x50x20mm
box = Part.makeBox(50, 50, 20)
box_obj = doc.addObject("Part::Feature", "TestBox")
box_obj.Shape = box
doc.recompute()
# Calculate initial volume
initial_volume = box.Volume
# Create SmartCutter with parameters
params = {
"plane_type": "Preset Plane",
"plane": "XY",
"offset": 10.0, # Cut in the middle
"diameter": 5.0,
"depth": 5.0,
"hole_count": 4,
"clearance_preferred": 2.0,
"clearance_min": 0.5,
}
cutter = SmartCutter(box_obj, params)
# First cut the object to get the two halves
bottom_shape, top_shape = cutter.cut_object()
# Get the cut face center and face for hole position generation
normal, _ = cutter.get_cut_plane_normal_and_point()
bottom_face_center = cutter.get_cut_face_center(bottom_shape, -normal)
# Find the actual bottom cut face
bottom_cut_face = None
for face in bottom_shape.Faces:
if face.CenterOfMass.distanceToPoint(bottom_face_center) < 0.1:
bottom_cut_face = face
break
# Generate hole positions
positions, _, _, _ = cutter.generate_hole_positions(bottom_face_center, bottom_cut_face)
# Apply boolean hole method to bottom shape
# Note: holes go in -normal direction (into the bottom part)
bottom_with_holes = cutter._create_holes_boolean(bottom_shape, -normal, positions)
# Calculate expected volume reduction per hole
import math
hole_volume = math.pi * (params["diameter"] / 2) ** 2 * params["depth"]
expected_reduction = hole_volume * len(positions)
# Create Part::Feature objects for the results
bottom_result = doc.addObject("Part::Feature", "BottomWithHoles")
bottom_result.Shape = bottom_with_holes
doc.recompute()
# Verify results
_result_ = {
"success": True,
"initial_volume": initial_volume,
"bottom_half_volume_before": bottom_shape.Volume,
"bottom_with_holes_volume": bottom_with_holes.Volume,
"positions_count": len(positions),
"hole_volume_each": hole_volume,
"expected_volume_reduction": expected_reduction,
"actual_volume_reduction": bottom_shape.Volume - bottom_with_holes.Volume,
"bottom_valid": bottom_with_holes.isValid(),
"result_type": bottom_result.TypeId,
}
# Volume should have decreased by approximately the hole volumes
volume_diff = abs(_result_["actual_volume_reduction"] - expected_reduction)
_result_["volume_reduction_accurate"] = volume_diff < 1.0 # Within 1 mm^3 tolerance
""",
)
assert result["result"]["success"] is True
assert result["result"]["bottom_valid"] is True
assert (
result["result"]["result_type"] == "Part::Feature"
) # Boolean result is Part::Feature, not PartDesign
assert result["result"]["positions_count"] >= 1
# Volume should have decreased
assert (
result["result"]["bottom_with_holes_volume"]
< result["result"]["bottom_half_volume_before"]
)
# Volume reduction should be close to expected
assert result["result"]["volume_reduction_accurate"] is True
def test_boolean_vs_partdesign_comparison(
self, xmlrpc_proxy: xmlrpc.client.ServerProxy
) -> None:
"""Compare boolean and PartDesign hole methods produce similar results."""
result = execute_code(
xmlrpc_proxy,
SMART_CUTTER_CODE
+ """
doc = App.ActiveDocument
# Create two identical solid boxes
box1 = Part.makeBox(50, 50, 20)
box1_obj = doc.addObject("Part::Feature", "Box1")
box1_obj.Shape = box1
box2 = Part.makeBox(50, 50, 20)
box2_obj = doc.addObject("Part::Feature", "Box2")
box2_obj.Shape = box2
doc.recompute()
# Same parameters for both
params = {
"plane_type": "Preset Plane",
"plane": "XY",
"offset": 10.0,
"diameter": 5.0,
"depth": 5.0,
"hole_count": 4,
"clearance_preferred": 3.0,
"clearance_min": 1.0,
}
# Test 1: PartDesign method
cutter1 = SmartCutter(box1_obj, params)
pd_bottom, pd_top = cutter1.execute()
# Test 2: Boolean method (manual process)
cutter2 = SmartCutter(box2_obj, params)
bottom_shape, top_shape = cutter2.cut_object()
normal, _ = cutter2.get_cut_plane_normal_and_point()
bottom_face_center = cutter2.get_cut_face_center(bottom_shape, -normal)
bottom_cut_face = None
for face in bottom_shape.Faces:
if face.CenterOfMass.distanceToPoint(bottom_face_center) < 0.1:
bottom_cut_face = face
break
positions, _, _, _ = cutter2.generate_hole_positions(bottom_face_center, bottom_cut_face)
bool_bottom = cutter2._create_holes_boolean(bottom_shape, -normal, positions)
bool_top = cutter2._create_holes_boolean(top_shape, normal, positions)
# Create result objects for boolean method
bool_bottom_obj = doc.addObject("Part::Feature", "BoolBottom")
bool_bottom_obj.Shape = bool_bottom
bool_top_obj = doc.addObject("Part::Feature", "BoolTop")
bool_top_obj.Shape = bool_top
doc.recompute()
# Compare results
pd_bottom_vol = pd_bottom.Shape.Volume
pd_top_vol = pd_top.Shape.Volume
bool_bottom_vol = bool_bottom.Volume
bool_top_vol = bool_top.Volume
_result_ = {
"success": True,
"partdesign_bottom_volume": pd_bottom_vol,
"partdesign_top_volume": pd_top_vol,
"boolean_bottom_volume": bool_bottom_vol,
"boolean_top_volume": bool_top_vol,
"partdesign_bottom_type": pd_bottom.TypeId,
"boolean_bottom_type": bool_bottom_obj.TypeId,
"volumes_match": abs(pd_bottom_vol - bool_bottom_vol) < 5.0 and abs(pd_top_vol - bool_top_vol) < 5.0,
"pd_bottom_valid": pd_bottom.Shape.isValid(),
"pd_top_valid": pd_top.Shape.isValid(),
"bool_bottom_valid": bool_bottom.isValid(),
"bool_top_valid": bool_top.isValid(),
}
""",
)
assert result["result"]["success"] is True
# Both methods should produce valid shapes
assert result["result"]["pd_bottom_valid"] is True
assert result["result"]["pd_top_valid"] is True
assert result["result"]["bool_bottom_valid"] is True
assert result["result"]["bool_top_valid"] is True
# PartDesign produces Body objects, boolean produces Part::Feature
assert result["result"]["partdesign_bottom_type"] == "PartDesign::Body"
assert result["result"]["boolean_bottom_type"] == "Part::Feature"
# Volumes should be similar (within tolerance for floating point differences)
assert result["result"]["volumes_match"] is True
class TestEdgeCases:
"""Tests for edge cases and error handling."""
@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 "EdgeCaseTestDoc" in FreeCAD.listDocuments():
FreeCAD.closeDocument("EdgeCaseTestDoc")
doc = FreeCAD.newDocument("EdgeCaseTestDoc")
_result_ = True
""",
)
def test_single_hole(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Test with just one hole requested."""
result = execute_code(
xmlrpc_proxy,
SMART_CUTTER_CODE
+ """
doc = App.ActiveDocument
box = Part.makeBox(50, 50, 40)
box_obj = doc.addObject("Part::Feature", "TestBox")
box_obj.Shape = box
doc.recompute()
params = {
"plane_type": "Preset Plane",
"plane": "XY",
"offset": 20.0,
"diameter": 6.0,
"depth": 3.0,
"hole_count": 1, # Single hole
"clearance_preferred": 2.0,
"clearance_min": 0.5,
}
cutter = SmartCutter(box_obj, params)
bottom_body, top_body = cutter.execute()
doc.recompute()
# Count geometry points in the sketches
bottom_sketch = None
for obj in bottom_body.Group:
if obj.TypeId == "Sketcher::SketchObject":
bottom_sketch = obj
break
_result_ = {
"success": True,
"bottom_valid": bottom_body.Shape.isValid(),
"top_valid": top_body.Shape.isValid(),
"sketch_geometry_count": bottom_sketch.GeometryCount if bottom_sketch else 0,
}
""",
)
assert result["result"]["success"] is True
assert result["result"]["bottom_valid"] is True
assert result["result"]["top_valid"] is True
# Should have exactly 1 point in sketch for single hole
assert result["result"]["sketch_geometry_count"] == 1
def test_many_holes(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None:
"""Test with many holes requested (some may be skipped due to overlap)."""
result = execute_code(
xmlrpc_proxy,
SMART_CUTTER_CODE
+ """
doc = App.ActiveDocument
# Create a larger box to fit more holes
box = Part.makeBox(100, 100, 40)
box_obj = doc.addObject("Part::Feature", "TestBox")
box_obj.Shape = box
doc.recompute()
params = {
"plane_type": "Preset Plane",
"plane": "XY",
"offset": 20.0,
"diameter": 4.0,
"depth": 3.0,
"hole_count": 20, # Many holes
"clearance_preferred": 2.0,
"clearance_min": 0.5,
}
cutter = SmartCutter(box_obj, params)
bottom_body, top_body = cutter.execute()
doc.recompute()
# Count geometry points in the sketches
bottom_sketch = None
for obj in bottom_body.Group:
if obj.TypeId == "Sketcher::SketchObject":
bottom_sketch = obj
break
_result_ = {
"success": True,
"bottom_valid": bottom_body.Shape.isValid(),
"top_valid": top_body.Shape.isValid(),
"sketch_geometry_count": bottom_sketch.GeometryCount if bottom_sketch else 0,
}
""",
)
assert result["result"]["success"] is True
assert result["result"]["bottom_valid"] is True
assert result["result"]["top_valid"] is True
# Should have multiple holes (exact count depends on overlap checking)
assert result["result"]["sketch_geometry_count"] >= 1