""" 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). Supports: - Object mode → rest pose (arm.bones) - Pose mode → current pose (obj.pose.bones) - Edit mode → current edit-bone positions Geometry: - UV-sphere at head and tail (head_radius / tail_radius) - Capped frustum cylinder connecting the two spheres """ import bpy import bmesh import math from mathutils import Vector, Matrix # --------------------------------------------------------------------------- # Geometry helpers # --------------------------------------------------------------------------- def _rotation_matrix_to_axis(target_axis: Vector) -> Matrix: """Return a 4x4 rotation matrix that rotates Z → target_axis.""" ax = target_axis.normalized() z = Vector((0.0, 0.0, 1.0)) cross = z.cross(ax) if cross.length < 1e-6: if ax.z > 0: return Matrix.Identity(4) else: return Matrix.Rotation(math.pi, 4, 'X') angle = math.acos(max(-1.0, min(1.0, z.dot(ax)))) return Matrix.Rotation(angle, 4, cross.normalized()) def add_sphere(bm: bmesh.types.BMesh, center: Vector, radius: float, segments: int): """Add a closed UV-sphere to bm.""" rings = max(segments // 2, 2) verts = [] for r in range(rings + 1): phi = math.pi * r / rings sin_phi = math.sin(phi) cos_phi = math.cos(phi) for s in range(segments): theta = 2.0 * math.pi * s / segments v = bm.verts.new(center + Vector(( radius * sin_phi * math.cos(theta), radius * sin_phi * math.sin(theta), radius * cos_phi, ))) verts.append(v) for r in range(rings): for s in range(segments): s_next = (s + 1) % segments v0 = verts[r * segments + s] v1 = verts[r * segments + s_next] v2 = verts[(r + 1) * segments + s_next] v3 = verts[(r + 1) * segments + s] try: bm.faces.new((v0, v1, v2, v3)) except ValueError: pass return verts def add_capped_frustum(bm: bmesh.types.BMesh, head_center: Vector, head_radius: float, tail_center: Vector, tail_radius: float, segments: int): """ Add a capped tapered cylinder between two sphere centers. Rings are placed at the sphere equators (offset = radius along axis) so they sit flush with the sphere surfaces. Each open end is closed with a triangle fan cap. """ axis = tail_center - head_center length = axis.length if length < 1e-6: return ax = axis.normalized() rot = _rotation_matrix_to_axis(ax) # Place rings at the sphere equators along the bone axis head_ring_center = head_center + ax * head_radius tail_ring_center = tail_center - ax * tail_radius # Skip if rings would overlap (bone too short relative to radii) if (tail_ring_center - head_ring_center).dot(ax) < 1e-6: return # Build the two rings head_ring = [] tail_ring = [] for i in range(segments): theta = 2.0 * math.pi * i / segments local = Vector((math.cos(theta), math.sin(theta), 0.0)) # rot is 4x4; multiply then drop w component offset = (rot @ local.to_4d()).to_3d() head_ring.append(bm.verts.new(head_ring_center + offset * head_radius)) tail_ring.append(bm.verts.new(tail_ring_center + offset * tail_radius)) # Side quads 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 # Head cap — fan, winding faces inward (away from tail) head_cap_v = bm.verts.new(head_ring_center) for i in range(segments): nxt = (i + 1) % segments try: bm.faces.new((head_cap_v, head_ring[nxt], head_ring[i])) except ValueError: pass # Tail cap — fan, winding faces outward (away from head) tail_cap_v = bm.verts.new(tail_ring_center) for i in range(segments): nxt = (i + 1) % segments try: bm.faces.new((tail_cap_v, tail_ring[i], tail_ring[nxt])) except ValueError: pass # --------------------------------------------------------------------------- # 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 an envelope-style mesh. " "Object mode = rest pose | Pose mode = current pose | Edit mode = edit bones." ) bl_options = {'REGISTER', 'UNDO'} segments: bpy.props.IntProperty( name="Segments", description="Sphere / cylinder 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): arm_obj = context.active_object original_mode = arm_obj.mode # Snapshot bone data NOW, while we're still in the original mode. # This is especially important for EDIT mode where edit_bones are live. # bones_data = collect_bone_data(arm_obj) // we arent doing this anymore mode = arm_obj.mode world_mat = arm_obj.matrix_world bm = bmesh.new() segs = self.segments def parts(mat, rad): return (world_mat @ mat, max(rad, 0.001)) def meshify(head_matrix, head_radius, tail_matrix, tail_radius): head_matrix_final, head_radius_final = parts(head_matrix, head_radius) tail_matrix_final, tail_radius_final = parts(tail_matrix, tail_radius) add_sphere(bm, head_matrix_final, head_radius_final, segs) add_sphere(bm, tail_matrix_final, tail_radius_final, segs) add_capped_frustum(bm, head_matrix_final, head_radius_final, tail_matrix_final, tail_radius_final, segs) if mode == 'EDIT': # edit_bones is accessible while in edit mode for bone in arm_obj.data.edit_bones: meshify(bone.head, bone.head_radius, bone.tail, bone.tail_radius) else: # OBJECT / everything else → rest pose for bone in arm_obj.data.bones: meshify(bone.head_local, bone.head_radius, bone.tail_local, bone.tail_radius) # Must be in OBJECT mode to create and link new mesh objects. if original_mode != 'OBJECT': bpy.ops.object.mode_set(mode='OBJECT') # Merge verts from shared joints (parent/child bone connections) bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0001) mesh = bpy.data.meshes.new(arm_obj.name + "_envelope_mesh") bm.to_mesh(mesh) bm.free() mesh.update() result_obj = bpy.data.objects.new(arm_obj.name + "_envelope", mesh) context.collection.objects.link(result_obj) # Re-enter edit mode on the armature if that's where we came from, # then make the new mesh the active/selected object. if original_mode == 'EDIT': arm_obj.select_set(True) context.view_layer.objects.active = arm_obj bpy.ops.object.mode_set(mode='EDIT') arm_obj.select_set(False) bpy.ops.object.select_all(action='DESELECT') result_obj.select_set(True) context.view_layer.objects.active = result_obj source_label = { 'OBJECT': "rest pose", 'POSE': "current pose", 'EDIT': "edit-bone layout", }.get(original_mode, original_mode) self.report({'INFO'}, f"Created '{result_obj.name}' from {source_label}.") 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): obj = context.active_object return obj is not None and obj.type == 'ARMATURE' def draw(self, context): layout = self.layout obj = context.active_object mode_labels = { 'OBJECT': "Object → rest pose", 'POSE': "Pose → current pose", 'EDIT': "Edit → edit bones", } layout.label(text=obj.name) layout.label(text=mode_labels.get(obj.mode, obj.mode), icon='INFO') 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()