""" Armature Mesher - Blender Addon Converts a selected armature into an envelope-style mesh using each bone's head/tail radius values (exactly like Envelope display mode). """ import bpy import bmesh import math from mathutils import Vector, Matrix, Quaternion # --------------------------------------------------------------------------- # Geometry helpers # --------------------------------------------------------------------------- def make_sphere_bmesh(bm, center: Vector, radius: float, segments: int = 12) -> list: """Add a UV-sphere to an existing BMesh, return the new verts.""" verts = [] rings = segments // 2 for ring in range(rings + 1): phi = math.pi * ring / rings # 0 … π for seg in range(segments): theta = 2 * math.pi * seg / segments x = radius * math.sin(phi) * math.cos(theta) y = radius * math.sin(phi) * math.sin(theta) z = radius * math.cos(phi) verts.append(bm.verts.new(center + Vector((x, y, z)))) # Connect rings for ring in range(rings): for seg in range(segments): nxt = (seg + 1) % segments v0 = verts[ring * segments + seg] v1 = verts[ring * segments + nxt] v2 = verts[(ring + 1) * segments + nxt] v3 = verts[(ring + 1) * segments + seg] try: bm.faces.new((v0, v1, v2, v3)) except ValueError: pass return verts # --------------------------------------------------------------------------- # Core build function # --------------------------------------------------------------------------- SEGMENTS = 16 # quality — increase for smoother result def build_envelope_mesh(armature_obj: bpy.types.Object) -> bpy.types.Object: arm = armature_obj.data world_mat = armature_obj.matrix_world bm = bmesh.new() for bone in arm.bones: # Bone head/tail in armature local space head_local = bone.head_local # Vector tail_local = bone.tail_local # Convert to world space head_w = world_mat @ head_local tail_w = world_mat @ tail_local head_r = bone.head_radius tail_r = bone.tail_radius # Clamp radii to something visible head_r = max(head_r, 0.001) tail_r = max(tail_r, 0.001) # Head sphere make_sphere_bmesh(bm, head_w, head_r, SEGMENTS) # Tail sphere make_sphere_bmesh(bm, tail_w, tail_r, SEGMENTS) # Connecting frustum axis = tail_w - head_w length = axis.length if length < 1e-6: continue ax = axis.normalized() def ring_offset(sphere_r, ring_r): rr = min(ring_r, sphere_r) return math.sqrt(max(sphere_r ** 2 - rr ** 2, 0.0)) head_off = ring_offset(head_r, head_r) tail_off = ring_offset(tail_r, tail_r) ring_head_pt = head_w + ax * head_off ring_tail_pt = tail_w - ax * tail_off # Only draw frustum if ring planes don't overlap if (ring_tail_pt - ring_head_pt).dot(ax) < 1e-6: continue # Build local rotation matrix aligning Z to bone axis z = Vector((0, 0, 1)) cross = z.cross(ax) if cross.length < 1e-6: rot = Matrix.Identity(4) if ax.z > 0 else Matrix.Rotation(math.pi, 4, 'X') else: angle = math.acos(max(-1.0, min(1.0, z.dot(ax)))) rot = Matrix.Rotation(angle, 4, cross.normalized()) head_ring = [] tail_ring = [] for i in range(SEGMENTS): theta = 2 * math.pi * i / SEGMENTS lv = Vector((math.cos(theta), math.sin(theta), 0.0)) rv = rot @ lv head_ring.append(bm.verts.new(ring_head_pt + rv * head_r)) tail_ring.append(bm.verts.new(ring_tail_pt + rv * tail_r)) for i in range(SEGMENTS): nxt = (i + 1) % SEGMENTS try: bm.faces.new((head_ring[i], head_ring[nxt], tail_ring[nxt], tail_ring[i])) except ValueError: pass # Merge overlapping verts (where spheres from adjacent bones touch) bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0001) # Create mesh data-block mesh = bpy.data.meshes.new(armature_obj.name + "_envelope_mesh") bm.to_mesh(mesh) bm.free() mesh.update() # Create object obj = bpy.data.objects.new(armature_obj.name + "_envelope", mesh) bpy.context.collection.objects.link(obj) return obj # --------------------------------------------------------------------------- # Operator # --------------------------------------------------------------------------- class ARMATURE_OT_build_envelope_mesh(bpy.types.Operator): bl_idname = "armature.build_envelope_mesh" bl_label = "Build Envelope Mesh" bl_description = ( "Convert the selected armature into a mesh that mirrors its " "Envelope display (spheres at joints, tapered cylinders along bones)" ) bl_options = {'REGISTER', 'UNDO'} segments: bpy.props.IntProperty( name="Segments", description="Cylinder / sphere resolution", default=16, min=4, max=64, ) @classmethod def poll(cls, context): obj = context.active_object return obj is not None and obj.type == 'ARMATURE' def execute(self, context): global SEGMENTS SEGMENTS = self.segments arm_obj = context.active_object # Ensure we have edit-mode bone data (rest-pose local coords) if arm_obj.mode != 'OBJECT': bpy.ops.object.mode_set(mode='OBJECT') result_obj = build_envelope_mesh(arm_obj) # Select the new mesh bpy.ops.object.select_all(action='DESELECT') result_obj.select_set(True) context.view_layer.objects.active = result_obj self.report({'INFO'}, f"Created: {result_obj.name}") return {'FINISHED'} # --------------------------------------------------------------------------- # Panel # --------------------------------------------------------------------------- class VIEW3D_PT_armature_mesher(bpy.types.Panel): bl_label = "Armature Mesher" bl_idname = "VIEW3D_PT_armature_mesher" bl_space_type = 'VIEW_3D' bl_region_type = 'UI' bl_category = "Armature" @classmethod def poll(cls, context): return (context.active_object is not None and context.active_object.type == 'ARMATURE') def draw(self, context): layout = self.layout layout.label(text="Selected: " + context.active_object.name) layout.separator() op = layout.operator( ARMATURE_OT_build_envelope_mesh.bl_idname, text="Build Envelope Mesh", icon='OUTLINER_OB_MESH', ) op.segments = 16 # --------------------------------------------------------------------------- # Registration # --------------------------------------------------------------------------- classes = ( ARMATURE_OT_build_envelope_mesh, VIEW3D_PT_armature_mesher, ) def register(): for cls in classes: bpy.utils.register_class(cls) def unregister(): for cls in reversed(classes): bpy.utils.unregister_class(cls) if __name__ == "__main__": register()