"""
Topology Analysis Handlers
Advanced topology analysis functions that run inside Blender.
Provides semantic quality metrics beyond simple polygon counting.
"""
import bpy
import bmesh
import math
import traceback
from collections import Counter
def get_topology_quality(object_name=None):
"""
Get detailed topology quality metrics for retopology evaluation.
Returns semantic metrics like quad percentage, pole distribution,
aspect ratios, and an overall quality score.
Parameters:
- object_name: Name of object to analyze (default: active object)
Returns dict with:
- quad_percentage, tri_percentage, ngon_percentage
- valence_distribution: {3: count, 4: count, 5: count, 6+: count}
- pole_count: vertices with valence != 4
- pole_locations: list of (x, y, z) for poles
- avg_aspect_ratio: average face elongation
- bad_aspect_count: faces with aspect ratio > 4
- quality_score: overall score 0-100
"""
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"}
else:
obj = bpy.context.active_object
if not obj or obj.type != 'MESH':
return {"error": "No valid mesh object selected"}
# Create bmesh for analysis
bm = bmesh.new()
bm.from_mesh(obj.data)
bm.verts.ensure_lookup_table()
bm.edges.ensure_lookup_table()
bm.faces.ensure_lookup_table()
total_faces = len(bm.faces)
total_verts = len(bm.verts)
if total_faces == 0:
bm.free()
return {"error": "Mesh has no faces"}
# Count face types
tri_count = 0
quad_count = 0
ngon_count = 0
for face in bm.faces:
vert_count = len(face.verts)
if vert_count == 3:
tri_count += 1
elif vert_count == 4:
quad_count += 1
else:
ngon_count += 1
# Calculate percentages
quad_percentage = (quad_count / total_faces) * 100
tri_percentage = (tri_count / total_faces) * 100
ngon_percentage = (ngon_count / total_faces) * 100
# Analyze vertex valence (number of edges connected to each vertex)
valence_distribution = {3: 0, 4: 0, 5: 0, "6+": 0}
pole_count = 0
pole_locations = []
for vert in bm.verts:
valence = len(vert.link_edges)
if valence == 3:
valence_distribution[3] += 1
pole_count += 1
pole_locations.append(tuple(obj.matrix_world @ vert.co))
elif valence == 4:
valence_distribution[4] += 1
elif valence == 5:
valence_distribution[5] += 1
pole_count += 1
pole_locations.append(tuple(obj.matrix_world @ vert.co))
else: # 6+
valence_distribution["6+"] += 1
pole_count += 1
pole_locations.append(tuple(obj.matrix_world @ vert.co))
# Calculate valence_4_ratio (percentage of verts with valence 4)
valence_4_ratio = (valence_distribution[4] / total_verts) * 100 if total_verts > 0 else 0
# Calculate aspect ratios for quads
aspect_ratios = []
bad_aspect_count = 0
for face in bm.faces:
if len(face.verts) == 4:
# Get edge lengths
edge_lengths = [e.calc_length() for e in face.edges]
if len(edge_lengths) == 4 and min(edge_lengths) > 0:
# For a quad, compare opposite edges
ratio1 = max(edge_lengths[0], edge_lengths[2]) / max(min(edge_lengths[0], edge_lengths[2]), 0.0001)
ratio2 = max(edge_lengths[1], edge_lengths[3]) / max(min(edge_lengths[1], edge_lengths[3]), 0.0001)
aspect = max(ratio1, ratio2)
aspect_ratios.append(aspect)
if aspect > 4.0:
bad_aspect_count += 1
avg_aspect_ratio = sum(aspect_ratios) / len(aspect_ratios) if aspect_ratios else 1.0
# Calculate overall quality score (0-100)
# Weighted factors:
# - Quad percentage: 40% weight
# - Valence 4 ratio: 30% weight
# - Aspect ratio quality: 20% weight
# - No ngons bonus: 10% weight
quad_score = min(quad_percentage, 100) * 0.4
valence_score = min(valence_4_ratio, 100) * 0.3
# Aspect ratio score: 1.0 = perfect, 4.0+ = 0
aspect_score = max(0, (4.0 - avg_aspect_ratio) / 3.0) * 100 * 0.2
# Ngon penalty
ngon_score = 10 if ngon_count == 0 else max(0, 10 - ngon_percentage)
quality_score = quad_score + valence_score + aspect_score + ngon_score
# Limit pole locations to first 50 for performance
pole_locations = pole_locations[:50]
bm.free()
return {
"success": True,
"object_name": obj.name,
"total_faces": total_faces,
"total_vertices": total_verts,
"quad_count": quad_count,
"tri_count": tri_count,
"ngon_count": ngon_count,
"quad_percentage": round(quad_percentage, 2),
"tri_percentage": round(tri_percentage, 2),
"ngon_percentage": round(ngon_percentage, 2),
"valence_distribution": valence_distribution,
"valence_4_ratio": round(valence_4_ratio, 2),
"pole_count": pole_count,
"pole_locations": pole_locations,
"avg_aspect_ratio": round(avg_aspect_ratio, 2),
"bad_aspect_count": bad_aspect_count,
"quality_score": round(quality_score, 1)
}
except Exception as e:
traceback.print_exc()
return {"error": f"Failed to analyze topology quality: {str(e)}"}
def analyze_mesh_regions(object_name=None, num_regions=8):
"""
Analyze mesh by dividing into spatial regions for localized quality assessment.
Parameters:
- object_name: Name of object to analyze (default: active object)
- num_regions: Number of regions to divide mesh into (default: 8)
Returns dict with regions and their quality metrics.
"""
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"}
else:
obj = bpy.context.active_object
if not obj or obj.type != 'MESH':
return {"error": "No valid mesh object selected"}
# Create bmesh for analysis
bm = bmesh.new()
bm.from_mesh(obj.data)
bm.verts.ensure_lookup_table()
bm.faces.ensure_lookup_table()
if len(bm.faces) == 0:
bm.free()
return {"error": "Mesh has no faces"}
# Calculate bounding box
min_co = [float('inf')] * 3
max_co = [float('-inf')] * 3
for vert in bm.verts:
world_co = obj.matrix_world @ vert.co
for i in range(3):
min_co[i] = min(min_co[i], world_co[i])
max_co[i] = max(max_co[i], world_co[i])
# Divide into regions (2x2x2 grid = 8 regions)
region_size = [(max_co[i] - min_co[i]) / 2 for i in range(3)]
regions = []
for ix in range(2):
for iy in range(2):
for iz in range(2):
region_min = [
min_co[0] + ix * region_size[0],
min_co[1] + iy * region_size[1],
min_co[2] + iz * region_size[2]
]
region_max = [
region_min[0] + region_size[0],
region_min[1] + region_size[1],
region_min[2] + region_size[2]
]
# Find faces in this region
region_faces = []
region_quads = 0
region_tris = 0
region_ngons = 0
for face in bm.faces:
center = obj.matrix_world @ face.calc_center_median()
in_region = True
for i in range(3):
if center[i] < region_min[i] or center[i] > region_max[i]:
in_region = False
break
if in_region:
region_faces.append(face.index)
vert_count = len(face.verts)
if vert_count == 3:
region_tris += 1
elif vert_count == 4:
region_quads += 1
else:
region_ngons += 1
total_region_faces = len(region_faces)
if total_region_faces > 0:
quad_pct = (region_quads / total_region_faces) * 100
# Determine dominant type
if region_quads >= region_tris and region_quads >= region_ngons:
dominant = "quad"
elif region_tris >= region_ngons:
dominant = "tri"
else:
dominant = "ngon"
# Calculate region quality score
region_quality = min(quad_pct, 100)
# Identify issues
issues = []
if region_ngons > 0:
issues.append(f"{region_ngons} ngons")
if quad_pct < 80:
issues.append(f"low quad ratio ({quad_pct:.0f}%)")
regions.append({
"id": len(regions),
"center": [
(region_min[0] + region_max[0]) / 2,
(region_min[1] + region_max[1]) / 2,
(region_min[2] + region_max[2]) / 2
],
"face_count": total_region_faces,
"face_indices": region_faces[:20], # Limit for performance
"dominant_type": dominant,
"quad_percentage": round(quad_pct, 1),
"quality_score": round(region_quality, 1),
"issues": issues
})
# Identify problem zones
problem_zones = [r["id"] for r in regions if r["quality_score"] < 70]
bm.free()
return {
"success": True,
"object_name": obj.name,
"regions": regions,
"problem_zones": problem_zones,
"total_regions": len(regions)
}
except Exception as e:
traceback.print_exc()
return {"error": f"Failed to analyze mesh regions: {str(e)}"}
def get_vertex_valence(object_name=None, min_valence=5):
"""
Get vertices with valence (edge count) >= threshold.
Parameters:
- object_name: Name of object to analyze (default: active object)
- min_valence: Minimum valence to include (default: 5)
Returns list of vertices with index, location, and valence.
"""
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"}
else:
obj = bpy.context.active_object
if not obj or obj.type != 'MESH':
return {"error": "No valid mesh object selected"}
# Create bmesh for analysis
bm = bmesh.new()
bm.from_mesh(obj.data)
bm.verts.ensure_lookup_table()
vertices = []
for vert in bm.verts:
valence = len(vert.link_edges)
if valence >= min_valence:
world_co = obj.matrix_world @ vert.co
vertices.append({
"index": vert.index,
"location": {
"x": round(world_co.x, 4),
"y": round(world_co.y, 4),
"z": round(world_co.z, 4)
},
"valence": valence
})
bm.free()
return {
"success": True,
"object_name": obj.name,
"min_valence": min_valence,
"vertices": vertices,
"count": len(vertices)
}
except Exception as e:
traceback.print_exc()
return {"error": f"Failed to get vertex valence: {str(e)}"}
def get_triangle_faces(object_name=None):
"""
Get all triangle faces (3 vertices) in the mesh.
Parameters:
- object_name: Name of object to analyze (default: active object)
Returns list of triangles with index, center, and vertex indices.
"""
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"}
else:
obj = bpy.context.active_object
if not obj or obj.type != 'MESH':
return {"error": "No valid mesh object selected"}
# Create bmesh for analysis
bm = bmesh.new()
bm.from_mesh(obj.data)
bm.faces.ensure_lookup_table()
triangles = []
for face in bm.faces:
if len(face.verts) == 3:
center = obj.matrix_world @ face.calc_center_median()
triangles.append({
"index": face.index,
"center": {
"x": round(center.x, 4),
"y": round(center.y, 4),
"z": round(center.z, 4)
},
"vertices": [v.index for v in face.verts]
})
bm.free()
return {
"success": True,
"object_name": obj.name,
"triangles": triangles,
"count": len(triangles)
}
except Exception as e:
traceback.print_exc()
return {"error": f"Failed to get triangle faces: {str(e)}"}
def get_ngon_faces(object_name=None):
"""
Get all n-gon faces (5+ vertices) in the mesh.
Parameters:
- object_name: Name of object to analyze (default: active object)
Returns list of n-gons with index, center, and vertex 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"}
else:
obj = bpy.context.active_object
if not obj or obj.type != 'MESH':
return {"error": "No valid mesh object selected"}
# Create bmesh for analysis
bm = bmesh.new()
bm.from_mesh(obj.data)
bm.faces.ensure_lookup_table()
ngons = []
for face in bm.faces:
vert_count = len(face.verts)
if vert_count > 4:
center = obj.matrix_world @ face.calc_center_median()
ngons.append({
"index": face.index,
"center": {
"x": round(center.x, 4),
"y": round(center.y, 4),
"z": round(center.z, 4)
},
"vertex_count": vert_count,
"vertices": [v.index for v in face.verts]
})
bm.free()
return {
"success": True,
"object_name": obj.name,
"ngons": ngons,
"count": len(ngons)
}
except Exception as e:
traceback.print_exc()
return {"error": f"Failed to get n-gon faces: {str(e)}"}
def analyze_cylindrical_structure(object_name=None, analyze_spacing=False):
"""
Analyze cylindrical mesh regions from selected edge loop.
Detects segment count, loop structure, and axis orientation
for cylindrical topology like limbs, pipes, or tubular forms.
Parameters:
- object_name: Name of object (default: active object)
- analyze_spacing: Include edge loop spacing analysis (default: False)
Returns dict with segment count, loop vertex count, and axis info.
"""
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 not already
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()
bm.edges.ensure_lookup_table()
# Find selected edges (should be an edge loop)
selected_edges = [e for e in bm.edges if e.select]
if len(selected_edges) == 0:
return {"error": "No edges selected. Select an edge loop first."}
# Find selected vertices
selected_verts = set()
for edge in selected_edges:
selected_verts.add(edge.verts[0])
selected_verts.add(edge.verts[1])
loop_vertex_count = len(selected_verts)
if loop_vertex_count < 3:
return {"error": "Selection doesn't form a valid loop"}
# Calculate center of the loop
import mathutils
loop_center = mathutils.Vector((0, 0, 0))
for vert in selected_verts:
loop_center += vert.co
loop_center /= loop_vertex_count
# Calculate average normal (axis direction)
loop_normal = mathutils.Vector((0, 0, 0))
for edge in selected_edges:
for face in edge.link_faces:
loop_normal += face.normal
if loop_normal.length > 0:
loop_normal.normalize()
# Determine primary axis
abs_normal = [abs(loop_normal.x), abs(loop_normal.y), abs(loop_normal.z)]
max_axis_idx = abs_normal.index(max(abs_normal))
axis_names = ["X", "Y", "Z"]
primary_axis = axis_names[max_axis_idx]
# Calculate average radius
total_radius = 0
for vert in selected_verts:
dist = (vert.co - loop_center).length
total_radius += dist
avg_radius = total_radius / loop_vertex_count
# Detect if this forms a proper cylindrical structure
# by checking if vertices are roughly equidistant from center
radii = [(vert.co - loop_center).length for vert in selected_verts]
radius_variance = sum((r - avg_radius) ** 2 for r in radii) / loop_vertex_count
is_cylindrical = radius_variance < (avg_radius * 0.2) ** 2 # 20% tolerance
result = {
"success": True,
"object_name": obj.name,
"is_cylindrical": is_cylindrical,
"segment_count": loop_vertex_count,
"loop_vertex_count": loop_vertex_count,
"primary_axis": primary_axis,
"center": list(loop_center),
"avg_radius": round(avg_radius, 4),
"radius_variance": round(radius_variance, 6)
}
# Suggest optimal segment count (power of 2 multiples work best)
common_segments = [8, 12, 16, 24, 32]
suggested = min(common_segments, key=lambda x: abs(x - loop_vertex_count))
result["suggested_segments"] = suggested
# Analyze spacing if requested
if analyze_spacing:
# Try to find parallel edge loops
# Walk perpendicular to the loop to find adjacent loops
parallel_loops = 1 # Current loop
# This is simplified - proper implementation would walk edges
result["edge_loop_count"] = parallel_loops
result["average_spacing"] = 0.0 # Would need proper calculation
return result
except Exception as e:
traceback.print_exc()
return {"error": f"Failed to analyze cylindrical structure: {str(e)}"}
def find_support_loops(object_name=None, min_angle=30.0, near_sharp=True, suggest=False):
"""
Find edge loops suitable for subdivision support.
Identifies edge loops near sharp edges or at significant angle
transitions that could serve as support loops for subdivision surfaces.
Parameters:
- object_name: Name of object (default: active object)
- min_angle: Minimum edge angle to consider (degrees, default: 30)
- near_sharp: Also find loops near sharp-marked edges (default: True)
- suggest: Suggest where to add support loops (default: False)
Returns list of candidate edge loop indices.
"""
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"}
else:
obj = bpy.context.active_object
if not obj or obj.type != 'MESH':
return {"error": "No valid mesh object selected"}
# Create bmesh for analysis
bm = bmesh.new()
bm.from_mesh(obj.data)
bm.edges.ensure_lookup_table()
angle_threshold = math.radians(min_angle)
candidate_edges = []
sharp_edges = []
# Find edges with significant angle
for edge in bm.edges:
if len(edge.link_faces) == 2:
angle = edge.calc_face_angle()
if abs(angle) >= angle_threshold:
candidate_edges.append({
"index": edge.index,
"angle": math.degrees(abs(angle)),
"type": "angle"
})
# Check for sharp-marked edges
if near_sharp and edge.smooth == False:
sharp_edges.append({
"index": edge.index,
"type": "sharp"
})
# Find parallel edges to sharp edges (potential support loops)
support_candidates = []
for sharp in sharp_edges:
edge = bm.edges[sharp["index"]]
# Look for nearby parallel edges
for vert in edge.verts:
for linked_edge in vert.link_edges:
if linked_edge.index != edge.index:
# Check if edges are roughly parallel
e1_vec = edge.verts[1].co - edge.verts[0].co
e2_vec = linked_edge.verts[1].co - linked_edge.verts[0].co
if e1_vec.length > 0 and e2_vec.length > 0:
e1_vec.normalize()
e2_vec.normalize()
dot = abs(e1_vec.dot(e2_vec))
if dot > 0.8: # Roughly parallel
support_candidates.append(linked_edge.index)
# Remove duplicates
support_candidates = list(set(support_candidates))
# Generate suggestions if requested
support_suggestions = []
if suggest:
# Suggest adding support loops near sharp edges that don't have nearby parallel edges
existing_support_set = set(support_candidates)
for sharp in sharp_edges[:20]: # Limit analysis
edge = bm.edges[sharp["index"]]
has_support = False
for vert in edge.verts:
for linked_edge in vert.link_edges:
if linked_edge.index in existing_support_set:
has_support = True
break
if has_support:
break
if not has_support:
# Suggest adding support loop here
center = (edge.verts[0].co + edge.verts[1].co) / 2
support_suggestions.append({
"location": [center.x, center.y, center.z],
"near_edge": edge.index,
"reason": "Sharp edge without nearby support loop"
})
bm.free()
return {
"success": True,
"object_name": obj.name,
"min_angle": min_angle,
"near_sharp": near_sharp,
"angle_edges": candidate_edges[:50], # Limit
"sharp_edges": sharp_edges[:50],
"support_candidates": support_candidates[:50],
"support_suggestions": support_suggestions[:10],
"total_angle_edges": len(candidate_edges),
"total_sharp_edges": len(sharp_edges),
"total_support_candidates": len(support_candidates),
"sharp_edge_count": len(sharp_edges),
"candidate_edges": candidate_edges[:50]
}
except Exception as e:
traceback.print_exc()
return {"error": f"Failed to find support loops: {str(e)}"}