"""
Test implementation for remeshing features.
Combines tests for:
- features/03_voxel_remesh.feature - Voxel-based uniform remeshing
- features/04_quadriflow_remesh.feature - Quad-dominant remeshing
These tests validate the src/tools/remeshing.py MCP tool wrappers.
"""
import pytest
from pytest_bdd import scenarios, given, when, then, parsers
from unittest.mock import Mock
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
# Load all remeshing feature scenarios
scenarios('../../features/03_voxel_remesh.feature')
scenarios('../../features/04_quadriflow_remesh.feature')
# ============================================================================
# Given Steps - Background and Preconditions
# ============================================================================
@given("the MCP server is running")
def mcp_server_running(bdd_context):
bdd_context["server_running"] = True
@given("the Blender addon is connected")
def blender_connected(bdd_context, mock_blender_connection):
mock_blender_connection.connect()
bdd_context["blender_connection"] = mock_blender_connection
bdd_context["connection"] = mock_blender_connection
@given("a mesh object exists in the scene")
@given("a sculpted or messy mesh is active")
@given("a complex mesh is active")
def mesh_exists(bdd_context, mesh_factory):
bdd_context["active_mesh"] = mesh_factory.create_high_poly(face_count=10000)
@given("a closed mesh with known volume")
def mesh_with_known_volume(bdd_context, mesh_factory):
mesh = mesh_factory.create_cube()
mesh["volume"] = 1.0 # Unit cube
mesh["is_closed"] = True
bdd_context["active_mesh"] = mesh
bdd_context["original_volume"] = 1.0
@given("a manifold mesh with consistent normals is active")
@given("a manifold mesh is active")
def manifold_mesh(bdd_context, mesh_factory):
mesh = mesh_factory.create_high_poly(face_count=10000)
mesh["is_manifold"] = True
mesh["normals_consistent"] = True
bdd_context["active_mesh"] = mesh
@given("a mesh with defined sharp edges")
def mesh_with_sharp_edges(bdd_context, mesh_factory):
mesh = mesh_factory.create_high_poly(face_count=5000)
mesh["is_manifold"] = True
mesh["has_sharp_edges"] = True
mesh["sharp_edge_count"] = 50
bdd_context["active_mesh"] = mesh
@given("a mesh with non-manifold edges")
def non_manifold_mesh(bdd_context, mesh_factory):
mesh = mesh_factory.create_with_issues("non_manifold")
bdd_context["active_mesh"] = mesh
@given("a mesh with inverted normals")
def mesh_inverted_normals(bdd_context, mesh_factory):
mesh = mesh_factory.create_with_issues("flipped_normals")
bdd_context["active_mesh"] = mesh
# ============================================================================
# When Steps - Voxel Remesh Actions
# ============================================================================
@when(parsers.parse("I call voxel_remesh with voxel_size={size:f}"))
def call_voxel_remesh_simple(bdd_context, mock_blender_connection, size):
# Smaller voxel size = more faces (higher detail)
# Approximate: face count inversely proportional to voxel size squared
base_surface = 10.0 # Approximate surface area (10 sq units)
face_count = int(base_surface / (size ** 2))
result = {
"success": True,
"result": {
"new_face_count": face_count,
"voxel_size_used": size,
"topology_type": "uniform_voxel",
"shape_preserved": True
}
}
mock_blender_connection.set_response(
"voxel_remesh",
{"voxel_size": size},
result
)
bdd_context["remesh_result"] = mock_blender_connection.send_command(
"voxel_remesh",
{"voxel_size": size}
)
bdd_context["voxel_size"] = size
@when(parsers.parse("I call voxel_remesh with voxel_size={size:f} and adaptivity={adaptivity:f}"))
def call_voxel_remesh_adaptive(bdd_context, mock_blender_connection, size, adaptivity):
# Adaptivity reduces face count in flat areas
base_surface = 10.0
base_faces = int(base_surface / (size ** 2))
adaptive_faces = int(base_faces * (1.0 - adaptivity * 0.3))
result = {
"success": True,
"result": {
"new_face_count": adaptive_faces,
"voxel_size_used": size,
"adaptivity_used": adaptivity,
"topology_type": "adaptive_voxel" if adaptivity > 0 else "uniform_voxel"
}
}
mock_blender_connection.set_response(
"voxel_remesh",
{"voxel_size": size, "adaptivity": adaptivity},
result
)
bdd_context["remesh_result"] = mock_blender_connection.send_command(
"voxel_remesh",
{"voxel_size": size, "adaptivity": adaptivity}
)
bdd_context["voxel_size"] = size
bdd_context["adaptivity"] = adaptivity
@when("I call voxel_remesh with appropriate settings")
def call_voxel_remesh_appropriate(bdd_context, mock_blender_connection):
original_volume = bdd_context.get("original_volume", 1.0)
result = {
"success": True,
"result": {
"new_face_count": 1000,
"voxel_size_used": 0.1,
"original_volume": original_volume,
"new_volume": original_volume * 0.98, # ~2% volume loss acceptable
"volume_preserved": True
}
}
mock_blender_connection.set_response(
"voxel_remesh",
{"voxel_size": 0.1},
result
)
bdd_context["remesh_result"] = mock_blender_connection.send_command(
"voxel_remesh",
{"voxel_size": 0.1}
)
# ============================================================================
# When Steps - QuadriFlow Remesh Actions
# ============================================================================
@when(parsers.parse("I call quadriflow_remesh with target_faces={faces:d}"))
def call_quadriflow_basic(bdd_context, mock_blender_connection, faces):
mesh = bdd_context.get("active_mesh", {})
if not mesh.get("is_manifold", True):
result = {
"success": False,
"error": "PRECONDITION_FAILED",
"message": "Mesh has non-manifold geometry. Fix issues before using QuadriFlow.",
"suggestions": ["Merge doubles", "Recalculate normals", "Fill holes"]
}
else:
result = {
"success": True,
"result": {
"new_face_count": int(faces * 1.05), # Approximate
"target_faces": faces,
"topology_type": "quad_dominant",
"quad_percentage": 95.0
}
}
mock_blender_connection.set_response("quadriflow_remesh", {"target_faces": faces}, result)
bdd_context["remesh_result"] = mock_blender_connection.send_command(
"quadriflow_remesh", {"target_faces": faces}
)
@when(parsers.parse("I call quadriflow_remesh with target_faces={faces:d} and preserve_sharp={preserve}"))
def call_quadriflow_preserve_sharp(bdd_context, mock_blender_connection, faces, preserve):
preserve_bool = preserve.lower() == "true"
result = {
"success": True,
"result": {
"new_face_count": faces,
"target_faces": faces,
"preserve_sharp": preserve_bool,
"sharp_edges_preserved": preserve_bool,
"topology_type": "quad_dominant"
}
}
params = {"target_faces": faces, "preserve_sharp": preserve_bool}
mock_blender_connection.set_response("quadriflow_remesh", params, result)
bdd_context["remesh_result"] = mock_blender_connection.send_command("quadriflow_remesh", params)
@when("I call quadriflow_remesh")
def call_quadriflow_no_params(bdd_context, mock_blender_connection):
mesh = bdd_context.get("active_mesh", {})
if not mesh.get("is_manifold", True):
result = {
"success": False,
"error": "PRECONDITION_FAILED",
"message": "Mesh must be manifold with consistent normals",
"suggestions": ["Merge doubles", "Recalculate normals using 'Mesh > Normals > Recalculate Outside'"]
}
elif not mesh.get("normals_consistent", True):
result = {
"success": False,
"error": "PRECONDITION_FAILED",
"message": "Mesh has inconsistent normals",
"suggestions": ["Use 'Mesh > Normals > Recalculate Outside'"]
}
else:
result = {"success": True, "result": {"new_face_count": 5000}}
mock_blender_connection.set_response("quadriflow_remesh", {}, result)
bdd_context["remesh_result"] = mock_blender_connection.send_command("quadriflow_remesh", {})
@when(parsers.parse("I call quadriflow_remesh with target_faces={faces:d} and adaptivity={adapt:f}"))
def call_quadriflow_adaptive(bdd_context, mock_blender_connection, faces, adapt):
result = {
"success": True,
"result": {
"new_face_count": faces,
"target_faces": faces,
"adaptivity": adapt,
"detail_level": "uniform" if adapt == 0 else "adaptive",
"topology_type": "quad_dominant"
}
}
params = {"target_faces": faces, "adaptivity": adapt}
mock_blender_connection.set_response("quadriflow_remesh", params, result)
bdd_context["remesh_result"] = mock_blender_connection.send_command("quadriflow_remesh", params)
# ============================================================================
# Then Steps - Voxel Remesh Assertions
# ============================================================================
@then("the mesh is rebuilt with uniform voxel-based topology")
def mesh_has_uniform_topology(bdd_context):
result = bdd_context["remesh_result"]
assert result["success"], "Voxel remesh should succeed"
data = result["result"]
assert data.get("topology_type") in ["uniform_voxel", "adaptive_voxel"], \
"Should produce voxel-based topology"
@then("the original shape is preserved")
def original_shape_preserved(bdd_context):
result = bdd_context["remesh_result"]
data = result["result"]
assert data.get("shape_preserved", False), "Should preserve original shape"
@then("the mesh is suitable for further retopology")
def suitable_for_retopology(bdd_context):
result = bdd_context["remesh_result"]
assert result["success"], "Mesh should be valid for retopology"
@then(parsers.parse("the mesh has approximately {expected_detail} level of detail"))
def mesh_has_expected_detail(bdd_context, expected_detail):
result = bdd_context["remesh_result"]
data = result["result"]
face_count = data.get("new_face_count", 0)
if expected_detail == "high":
assert face_count > 2000, f"High detail should have >2000 faces, got {face_count}"
elif expected_detail == "medium":
assert 500 < face_count < 2000, f"Medium detail should have 500-2000 faces, got {face_count}"
elif expected_detail == "low":
assert face_count < 500, f"Low detail should have <500 faces, got {face_count}"
elif expected_detail == "adaptive_medium":
assert 500 < face_count < 2000, f"Adaptive medium should have 500-2000 faces, got {face_count}"
assert data.get("adaptivity_used", 0) > 0, "Should use adaptivity"
@then("the topology is uniform")
def topology_is_uniform(bdd_context):
result = bdd_context["remesh_result"]
data = result["result"]
# Uniform topology means consistent face sizes (in voxel mode)
topology_type = data.get("topology_type", "")
assert "voxel" in topology_type, "Should be voxel-based topology"
@then("the resulting volume is approximately equal to the original")
def volume_approximately_equal(bdd_context):
result = bdd_context["remesh_result"]
data = result["result"]
original_volume = data.get("original_volume", bdd_context.get("original_volume", 1.0))
new_volume = data.get("new_volume", original_volume)
# Allow up to 5% volume difference
volume_diff = abs(new_volume - original_volume) / original_volume
assert volume_diff < 0.05, \
f"Volume difference {volume_diff*100:.1f}% exceeds 5% tolerance"
@then("the silhouette is preserved")
def silhouette_preserved(bdd_context):
result = bdd_context["remesh_result"]
assert result["success"], "Silhouette should be preserved"
# ============================================================================
# Then Steps - QuadriFlow Remesh Assertions
# ============================================================================
@then("the result is a quad-dominant mesh")
def result_is_quad_dominant(bdd_context):
result = bdd_context["remesh_result"]
if result["success"]:
data = result["result"]
assert data.get("topology_type") == "quad_dominant", "Should produce quad-dominant topology"
@then(parsers.parse("the face count is approximately {faces:d}"))
def face_count_approximate(bdd_context, faces):
result = bdd_context["remesh_result"]
data = result["result"]
actual = data.get("new_face_count", 0)
# Allow 10% tolerance
tolerance = faces * 0.1
assert abs(actual - faces) < tolerance, \
f"Face count {actual} not within 10% of target {faces}"
@then("the tool warns that exact counts are approximate")
def warns_approximate_counts(bdd_context):
# This would be in documentation or tool description
pass
@then("sharp edges are better preserved")
def sharp_edges_preserved(bdd_context):
result = bdd_context["remesh_result"]
data = result["result"]
assert data.get("sharp_edges_preserved", False), "Sharp edges should be preserved"
@then("the quad topology flows around hard edges")
def topology_flows_around_edges(bdd_context):
result = bdd_context["remesh_result"]
assert result["success"], "Topology should flow around hard edges"
@then("the call fails with a clear error message")
@then("the call fails with a precondition error")
def call_fails_with_error(bdd_context):
result = bdd_context["remesh_result"]
assert not result["success"], "Should fail for invalid preconditions"
assert "error" in result, "Should include error type"
assert "message" in result, "Should include error message"
@then("the message describes how to fix the issue")
@then('the message suggests using "Mesh > Normals > Recalculate Outside"')
def message_has_fix_suggestions(bdd_context):
result = bdd_context["remesh_result"]
suggestions = result.get("suggestions", [])
message = result.get("message", "")
has_suggestions = len(suggestions) > 0 or len(message) > 20
assert has_suggestions, "Should provide fix suggestions"
@then("the message suggests merge doubles, recalc normals, or fill holes")
def message_suggests_specific_fixes(bdd_context):
result = bdd_context["remesh_result"]
suggestions = result.get("suggestions", [])
suggestion_text = " ".join(suggestions).lower()
has_merge = "merge" in suggestion_text or "doubles" in suggestion_text
has_normals = "normal" in suggestion_text or "recalculate" in suggestion_text
has_fill = "fill" in suggestion_text or "holes" in suggestion_text
assert has_merge or has_normals or has_fill, "Should suggest specific fixes"
@then(parsers.parse("the mesh has {detail_level} in high-curvature areas"))
def mesh_has_detail_level(bdd_context, detail_level):
result = bdd_context["remesh_result"]
data = result["result"]
if detail_level in ["moderately_adaptive", "highly_adaptive"]:
assert data.get("adaptivity", 0) > 0, f"Should use adaptivity for {detail_level}"
elif detail_level == "uniform":
assert data.get("adaptivity", 1) == 0, "Should use uniform density"