"""
Topology Cleanup Handlers
Handlers for cleaning up mesh topology issues.
"""
import bpy
import bmesh
import traceback
def tris_to_quads(object_name=None, angle_threshold=40.0):
"""
Convert triangle pairs to quads.
Parameters:
- object_name: Name of object (default: active object)
- angle_threshold: Maximum angle between face normals for merging (degrees)
Returns converted count and remaining triangles.
"""
try:
# Get target object
if object_name:
obj = bpy.data.objects.get(object_name)
if not obj:
return {"error": f"Object '{object_name}' not found"}
bpy.context.view_layer.objects.active = obj
else:
obj = bpy.context.active_object
if not obj or obj.type != 'MESH':
return {"error": "No valid mesh object selected"}
# Enter Edit Mode
if bpy.context.mode != 'EDIT_MESH':
bpy.ops.object.mode_set(mode='EDIT')
# Count triangles before
bm = bmesh.from_edit_mesh(obj.data)
tris_before = len([f for f in bm.faces if len(f.verts) == 3])
# Select all
bpy.ops.mesh.select_all(action='SELECT')
# Convert tris to quads
import math
bpy.ops.mesh.tris_convert_to_quads(
face_threshold=math.radians(angle_threshold),
shape_threshold=math.radians(angle_threshold)
)
# Count triangles after
bm = bmesh.from_edit_mesh(obj.data)
tris_after = len([f for f in bm.faces if len(f.verts) == 3])
converted = (tris_before - tris_after) // 2 # Each quad was 2 tris
return {
"success": True,
"object_name": obj.name,
"angle_threshold": angle_threshold,
"tris_before": tris_before,
"tris_after": tris_after,
"converted": converted
}
except Exception as e:
traceback.print_exc()
return {"error": f"Failed to convert tris to quads: {str(e)}"}
def align_vertex_to_loop(object_name=None, vertex_index=0, axis="Y", target_coord=0.0):
"""
Align a vertex coordinate to match edge loop position.
Parameters:
- object_name: Name of object (default: active object)
- vertex_index: Index of vertex to align
- axis: Axis to align ("X", "Y", or "Z")
- target_coord: Target coordinate value
Returns the new vertex position.
"""
try:
# Get target object
if object_name:
obj = bpy.data.objects.get(object_name)
if not obj:
return {"error": f"Object '{object_name}' not found"}
bpy.context.view_layer.objects.active = obj
else:
obj = bpy.context.active_object
if not obj or obj.type != 'MESH':
return {"error": "No valid mesh object selected"}
# Enter Edit Mode
if bpy.context.mode != 'EDIT_MESH':
bpy.ops.object.mode_set(mode='EDIT')
# Get bmesh
bm = bmesh.from_edit_mesh(obj.data)
bm.verts.ensure_lookup_table()
# Validate vertex index
if vertex_index < 0 or vertex_index >= len(bm.verts):
return {"error": f"Vertex index {vertex_index} out of range (0-{len(bm.verts)-1})"}
vert = bm.verts[vertex_index]
old_co = list(vert.co)
# Set the coordinate based on axis
axis = axis.upper()
axis_map = {"X": 0, "Y": 1, "Z": 2}
if axis not in axis_map:
return {"error": f"Invalid axis: {axis}. Must be X, Y, or Z"}
vert.co[axis_map[axis]] = target_coord
bmesh.update_edit_mesh(obj.data)
new_co = list(vert.co)
return {
"success": True,
"object_name": obj.name,
"vertex_index": vertex_index,
"axis": axis,
"old_position": {"x": old_co[0], "y": old_co[1], "z": old_co[2]},
"new_position": {"x": new_co[0], "y": new_co[1], "z": new_co[2]}
}
except Exception as e:
traceback.print_exc()
return {"error": f"Failed to align vertex: {str(e)}"}
def dissolve_edge_loop_by_selection(object_name=None):
"""
Dissolve currently selected edge loop.
The edge loop must be pre-selected in Edit Mode.
Parameters:
- object_name: Name of object (default: active object)
Returns success status and edge count.
"""
try:
# Get target object
if object_name:
obj = bpy.data.objects.get(object_name)
if not obj:
return {"error": f"Object '{object_name}' not found"}
bpy.context.view_layer.objects.active = obj
else:
obj = bpy.context.active_object
if not obj or obj.type != 'MESH':
return {"error": "No valid mesh object selected"}
# Must be in Edit Mode
if bpy.context.mode != 'EDIT_MESH':
bpy.ops.object.mode_set(mode='EDIT')
# Count selected edges before
bm = bmesh.from_edit_mesh(obj.data)
edges_selected = len([e for e in bm.edges if e.select])
if edges_selected == 0:
return {"error": "No edges selected. Select an edge loop first."}
# Check for potential n-gon creation
faces_before = len(bm.faces)
ngons_before = len([f for f in bm.faces if len(f.verts) > 4])
# Dissolve selected edges
bpy.ops.mesh.dissolve_edges()
# Update and check results
bm = bmesh.from_edit_mesh(obj.data)
faces_after = len(bm.faces)
ngons_after = len([f for f in bm.faces if len(f.verts) > 4])
ngons_created = ngons_after - ngons_before
return {
"success": True,
"object_name": obj.name,
"edges_dissolved": edges_selected,
"faces_before": faces_before,
"faces_after": faces_after,
"ngons_created": ngons_created,
"warning": "N-gons were created" if ngons_created > 0 else None
}
except Exception as e:
traceback.print_exc()
return {"error": f"Failed to dissolve edge loop: {str(e)}"}
def reduce_vertex_valence(object_name=None, vertex_index=0, target_valence=4):
"""
Attempt to reduce vertex valence by collapsing nearby edges.
Note: This is a complex operation that may not always succeed.
It's often better to manually fix high-valence vertices.
Parameters:
- object_name: Name of object (default: active object)
- vertex_index: Index of vertex to reduce
- target_valence: Target valence (minimum 3)
Returns success status.
"""
try:
# Get target object
if object_name:
obj = bpy.data.objects.get(object_name)
if not obj:
return {"error": f"Object '{object_name}' not found"}
bpy.context.view_layer.objects.active = obj
else:
obj = bpy.context.active_object
if not obj or obj.type != 'MESH':
return {"error": "No valid mesh object selected"}
if target_valence < 3:
return {"error": "Target valence must be at least 3"}
# Enter Edit Mode
if bpy.context.mode != 'EDIT_MESH':
bpy.ops.object.mode_set(mode='EDIT')
# Get bmesh
bm = bmesh.from_edit_mesh(obj.data)
bm.verts.ensure_lookup_table()
# Validate vertex index
if vertex_index < 0 or vertex_index >= len(bm.verts):
return {"error": f"Vertex index {vertex_index} out of range (0-{len(bm.verts)-1})"}
vert = bm.verts[vertex_index]
current_valence = len(vert.link_edges)
if current_valence <= target_valence:
return {
"success": True,
"object_name": obj.name,
"vertex_index": vertex_index,
"current_valence": current_valence,
"target_valence": target_valence,
"message": "Vertex already at or below target valence"
}
# For each edge that needs to be removed, try collapsing
# This is a simplified approach - proper implementation would need
# topology analysis to decide which edges to collapse
edges_to_reduce = current_valence - target_valence
# Deselect all
bpy.ops.mesh.select_all(action='DESELECT')
bpy.ops.mesh.select_mode(type='EDGE')
# Select the vertex's shortest edges for collapse
edges_by_length = sorted(vert.link_edges, key=lambda e: e.calc_length())
for i, edge in enumerate(edges_by_length[:edges_to_reduce]):
edge.select = True
bmesh.update_edit_mesh(obj.data)
# Collapse selected edges
bpy.ops.mesh.merge(type='COLLAPSE')
# Check result
bm = bmesh.from_edit_mesh(obj.data)
bm.verts.ensure_lookup_table()
# The vertex index may have changed, so we return a note
return {
"success": True,
"object_name": obj.name,
"original_vertex_index": vertex_index,
"original_valence": current_valence,
"target_valence": target_valence,
"edges_collapsed": edges_to_reduce,
"note": "Vertex indices may have changed after collapse"
}
except Exception as e:
traceback.print_exc()
return {"error": f"Failed to reduce vertex valence: {str(e)}"}
def fix_ngons(object_name=None, method='triangulate', select_all=True):
"""
Convert n-gon faces (5+ vertices) to quads or triangles.
Parameters:
- object_name: Name of object (default: active object)
- method: Conversion method:
- 'triangulate': Split n-gons into triangles (most reliable)
- 'poke': Create triangles radiating from center point
- 'fan': Fan triangulation from first vertex
- select_all: Process all n-gons in mesh (default: True), or only selected
Returns count of converted n-gons.
"""
try:
# Get target object
if object_name:
obj = bpy.data.objects.get(object_name)
if not obj:
return {"error": f"Object '{object_name}' not found"}
bpy.context.view_layer.objects.active = obj
else:
obj = bpy.context.active_object
if not obj or obj.type != 'MESH':
return {"error": "No valid mesh object selected"}
# Enter Edit Mode
if bpy.context.mode != 'EDIT_MESH':
bpy.ops.object.mode_set(mode='EDIT')
# Get bmesh and count n-gons before
bm = bmesh.from_edit_mesh(obj.data)
bm.faces.ensure_lookup_table()
ngons_before = len([f for f in bm.faces if len(f.verts) > 4])
if ngons_before == 0:
return {
"success": True,
"object_name": obj.name,
"ngons_before": 0,
"ngons_fixed": 0,
"message": "No n-gons found in mesh"
}
# Select n-gons if select_all is True
if select_all:
bpy.ops.mesh.select_all(action='DESELECT')
bpy.ops.mesh.select_mode(type='FACE')
bm = bmesh.from_edit_mesh(obj.data)
for face in bm.faces:
if len(face.verts) > 4:
face.select = True
bmesh.update_edit_mesh(obj.data)
# Apply conversion method
valid_methods = ['triangulate', 'poke', 'fan']
if method not in valid_methods:
return {"error": f"Invalid method '{method}'. Valid: {valid_methods}"}
if method == 'triangulate':
bpy.ops.mesh.quads_convert_to_tris(quad_method='BEAUTY', ngon_method='BEAUTY')
elif method == 'poke':
bpy.ops.mesh.poke()
elif method == 'fan':
bpy.ops.mesh.quads_convert_to_tris(quad_method='FIXED', ngon_method='CLIP')
# Count n-gons after
bm = bmesh.from_edit_mesh(obj.data)
ngons_after = len([f for f in bm.faces if len(f.verts) > 4])
return {
"success": True,
"object_name": obj.name,
"method": method,
"ngons_before": ngons_before,
"ngons_after": ngons_after,
"ngons_fixed": ngons_before - ngons_after
}
except Exception as e:
traceback.print_exc()
return {"error": f"Failed to fix n-gons: {str(e)}"}