"""
Mesh operator tools for retopology workflows.
Provides safe, parameterized tools for mesh editing operations.
"""
from typing import Dict, Any, List, Optional
from mcp.server.fastmcp import Context
import logging
logger = logging.getLogger("BlenderMCPServer")
# ============================================================================
# SYMMETRIZE
# ============================================================================
def symmetrize_mesh(
ctx: Context,
blender_connection,
direction: str = "NEGATIVE_X_TO_POSITIVE_X",
threshold: float = 0.0001
) -> str:
"""
Enforce mesh symmetry by mirroring one side to the other.
Cuts the mesh along an axis and mirrors one side to the other, copying
UVs, vertex colors, and weights. Requires Edit Mode.
Parameters:
- direction: Mirror direction - 'NEGATIVE_X_TO_POSITIVE_X', 'POSITIVE_X_TO_NEGATIVE_X',
'NEGATIVE_Y_TO_POSITIVE_Y', 'POSITIVE_Y_TO_NEGATIVE_Y',
'NEGATIVE_Z_TO_POSITIVE_Z', 'POSITIVE_Z_TO_NEGATIVE_Z' (default: 'NEGATIVE_X_TO_POSITIVE_X')
- threshold: Merge distance for symmetry plane (default: 0.0001)
Returns status message with operation results.
"""
try:
valid_directions = [
'NEGATIVE_X_TO_POSITIVE_X', 'POSITIVE_X_TO_NEGATIVE_X',
'NEGATIVE_Y_TO_POSITIVE_Y', 'POSITIVE_Y_TO_NEGATIVE_Y',
'NEGATIVE_Z_TO_POSITIVE_Z', 'POSITIVE_Z_TO_NEGATIVE_Z'
]
if direction not in valid_directions:
return f"Error: Invalid direction. Must be one of {valid_directions}."
result = blender_connection.send_command("symmetrize_mesh", {
"direction": direction,
"threshold": threshold
})
if "error" in result:
return f"Error: {result['error']}"
output = "Mesh Symmetrized!\n\n"
output += f"Direction: {direction}\n"
output += f"Threshold: {threshold}\n"
if 'vertices_created' in result:
output += f"Vertices Created: {result['vertices_created']}\n"
return output
except Exception as e:
logger.error(f"Error symmetrizing mesh: {str(e)}")
return f"Error symmetrizing mesh: {str(e)}"
# ============================================================================
# SMOOTH VERTICES
# ============================================================================
def smooth_vertices(
ctx: Context,
blender_connection,
iterations: int = 5,
factor: float = 0.5,
axes: str = "XYZ"
) -> str:
"""
Smooth selected vertices by averaging neighbor positions.
Relaxes topology without subdividing by averaging vertex positions across
adjacent faces. Works on selected vertices in Edit Mode.
Parameters:
- iterations: Number of smoothing iterations (default: 5)
- factor: Smoothing strength 0-1 (default: 0.5)
- axes: Axes to constrain smoothing - 'X', 'Y', 'Z', 'XY', 'XZ', 'YZ', 'XYZ' (default: 'XYZ')
Returns status message with operation results.
"""
try:
if iterations < 1:
return f"Error: Iterations must be at least 1."
if not 0.0 <= factor <= 1.0:
return f"Error: Factor must be between 0.0 and 1.0."
valid_axes = ['X', 'Y', 'Z', 'XY', 'XZ', 'YZ', 'XYZ']
if axes not in valid_axes:
return f"Error: Invalid axes '{axes}'. Must be one of {valid_axes}."
result = blender_connection.send_command("smooth_vertices", {
"iterations": iterations,
"factor": factor,
"axes": axes
})
if "error" in result:
return f"Error: {result['error']}"
output = "Vertices Smoothed!\n\n"
output += f"Iterations: {iterations}\n"
output += f"Factor: {factor}\n"
output += f"Axes: {axes}\n"
if 'vertices_affected' in result:
output += f"Vertices Affected: {result['vertices_affected']}\n"
return output
except Exception as e:
logger.error(f"Error smoothing vertices: {str(e)}")
return f"Error smoothing vertices: {str(e)}"
# ============================================================================
# RECALCULATE NORMALS
# ============================================================================
def recalculate_normals(
ctx: Context,
blender_connection,
inside: bool = False
) -> str:
"""
Recalculate face normals for consistent orientation.
Makes all face normals point consistently outward (or inward if specified).
Essential for fixing shading issues and preparing for remeshing. Requires Edit Mode.
Parameters:
- inside: Point normals inward instead of outward (default: false)
Returns status message with operation results.
"""
try:
result = blender_connection.send_command("recalculate_normals", {
"inside": inside
})
if "error" in result:
return f"Error: {result['error']}"
output = "Normals Recalculated!\n\n"
output += f"Direction: {'Inside' if inside else 'Outside'}\n"
if 'faces_flipped' in result:
output += f"Faces Flipped: {result['faces_flipped']}\n"
return output
except Exception as e:
logger.error(f"Error recalculating normals: {str(e)}")
return f"Error recalculating normals: {str(e)}"
# ============================================================================
# MERGE BY DISTANCE
# ============================================================================
def merge_by_distance(
ctx: Context,
blender_connection,
distance: float = 0.0001,
unselected: bool = False
) -> str:
"""
Merge vertices that are closer than the specified distance.
Removes duplicate and near-duplicate vertices to clean topology.
Works in Edit Mode on selected vertices (or all if unselected is true).
Parameters:
- distance: Maximum merge distance (default: 0.0001)
- unselected: Also merge unselected vertices (default: false)
Returns status message with number of vertices merged.
"""
try:
if distance < 0:
return f"Error: Distance must be non-negative."
result = blender_connection.send_command("merge_by_distance", {
"distance": distance,
"unselected": unselected
})
if "error" in result:
return f"Error: {result['error']}"
output = "Vertices Merged!\n\n"
output += f"Merge Distance: {distance}\n"
output += f"Include Unselected: {unselected}\n"
if 'vertices_removed' in result:
output += f"Vertices Removed: {result['vertices_removed']}\n"
return output
except Exception as e:
logger.error(f"Error merging by distance: {str(e)}")
return f"Error merging by distance: {str(e)}"
# ============================================================================
# DELETE LOOSE
# ============================================================================
def delete_loose(
ctx: Context,
blender_connection,
delete_faces: bool = False
) -> str:
"""
Delete loose vertices and edges not connected to faces.
Removes stray geometry that isn't part of the main mesh structure.
Requires Edit Mode with all geometry selected.
Parameters:
- delete_faces: Also delete loose faces (default: false)
Returns status message with elements removed.
"""
try:
result = blender_connection.send_command("delete_loose", {
"delete_faces": delete_faces
})
if "error" in result:
return f"Error: {result['error']}"
output = "Loose Geometry Deleted!\n\n"
if 'vertices_removed' in result:
output += f"Vertices Removed: {result['vertices_removed']}\n"
if 'edges_removed' in result:
output += f"Edges Removed: {result['edges_removed']}\n"
if 'faces_removed' in result and delete_faces:
output += f"Faces Removed: {result['faces_removed']}\n"
return output
except Exception as e:
logger.error(f"Error deleting loose geometry: {str(e)}")
return f"Error deleting loose geometry: {str(e)}"
# ============================================================================
# DISSOLVE EDGE LOOPS
# ============================================================================
def dissolve_edge_loops(
ctx: Context,
blender_connection
) -> str:
"""
Dissolve selected edge loops without leaving holes.
Removes redundant edge loops by merging surrounding faces. The edge loop
must be selected in Edit Mode. Useful for reducing edge flow complexity.
Returns status message with operation results.
"""
try:
result = blender_connection.send_command("dissolve_edge_loops", {})
if "error" in result:
return f"Error: {result['error']}"
output = "Edge Loops Dissolved!\n\n"
if 'edges_dissolved' in result:
output += f"Edges Dissolved: {result['edges_dissolved']}\n"
if 'faces_merged' in result:
output += f"Faces Merged: {result['faces_merged']}\n"
return output
except Exception as e:
logger.error(f"Error dissolving edge loops: {str(e)}")
return f"Error dissolving edge loops: {str(e)}"
# ============================================================================
# DISSOLVE SELECTED
# ============================================================================
def dissolve_selected(
ctx: Context,
blender_connection,
object_name: str = None,
mode: str = "VERT",
use_verts: bool = True,
use_face_split: bool = False
) -> str:
"""
Dissolve selected mesh elements without creating holes.
Removes selected vertices, edges, or faces while merging surrounding
geometry. Works in Edit Mode on currently selected elements.
Parameters:
- object_name: Name of object (default: active object)
- mode: Element type to dissolve - 'VERT' | 'EDGE' | 'FACE' (default: 'VERT')
- use_verts: For edge dissolve, also dissolve verts (default: true)
- use_face_split: Split non-planar faces during dissolve (default: false)
Returns status message with operation results.
"""
try:
valid_modes = ['VERT', 'EDGE', 'FACE']
if mode not in valid_modes:
return f"Error: Invalid mode '{mode}'. Must be one of {valid_modes}."
params = {
"mode": mode,
"use_verts": use_verts,
"use_face_split": use_face_split
}
if object_name:
params["object_name"] = object_name
result = blender_connection.send_command("dissolve_selected", params)
if "error" in result:
return f"Error: {result['error']}"
output = f"Elements Dissolved: {result.get('object_name', 'Unknown')}\n"
output += "=" * 50 + "\n\n"
output += f"Mode: {mode}\n"
output += f"Dissolved count: {result.get('dissolved_count', 0)}\n"
return output
except Exception as e:
logger.error(f"Error dissolving selected: {str(e)}")
return f"Error dissolving selected: {str(e)}"
# ============================================================================
# KNIFE PROJECT
# ============================================================================
def knife_project(
ctx: Context,
blender_connection,
cutter_object: str,
cut_through: bool = True
) -> str:
"""
Project a curve or mesh outline to cut the active mesh.
Projects the silhouette of the cutter object along the view axis to create
cut edges in the target mesh. Requires Edit Mode with target active and
cutter selected in outliner.
Parameters:
- cutter_object: Name of the curve or mesh object to use as cutter
- cut_through: Cut through entire mesh, not just visible faces (default: true)
Returns status message with operation results.
"""
try:
result = blender_connection.send_command("knife_project", {
"cutter_object": cutter_object,
"cut_through": cut_through
})
if "error" in result:
return f"Error: {result['error']}"
output = "Knife Project Complete!\n\n"
output += f"Cutter Object: {cutter_object}\n"
output += f"Cut Through: {cut_through}\n"
if 'edges_created' in result:
output += f"Edges Created: {result['edges_created']}\n"
return output
except Exception as e:
logger.error(f"Error in knife project: {str(e)}")
return f"Error in knife project: {str(e)}"