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, axis: Vector = None): """Add a closed UV-sphere to bm. If axis is provided, orient the sphere so its polar axis (sphere Z) aligns with `axis` — this makes the equator sit correctly where frustum rings expect it. """ rings = max(segments // 2, 2) verts = [] rot = None if axis is not None: # If axis is near-zero length, ignore orientation. if axis.length >= 1e-6: rot = _rotation_matrix_to_axis(axis.normalized()) 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 local = Vector(( radius * sin_phi * math.cos(theta), radius * sin_phi * math.sin(theta), radius * cos_phi, )) if rot is not None: offset = (rot @ local.to_4d()).to_3d() else: offset = local v = bm.verts.new(center + offset) 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, draw_head=True): head_matrix_final, head_radius_final = parts(head_matrix, head_radius) tail_matrix_final, tail_radius_final = parts(tail_matrix, tail_radius) # Orient spheres so their equators align with the bone axis axis_vec = tail_matrix_final - head_matrix_final if draw_head: add_sphere(bm, head_matrix_final, head_radius_final, segs, axis=axis_vec) add_sphere(bm, tail_matrix_final, tail_radius_final, segs, axis=axis_vec) add_capped_frustum(bm, head_matrix_final, head_radius_final, tail_matrix_final, tail_radius_final, segs) # pose mode or object mode: if mode == "EDIT": bone_list = arm_obj.data.edit_bones for bone in bone_list: meshify(bone.head, bone.head_radius, bone.tail, bone.tail_radius, bone.parent is None) else: bone_list = arm_obj.pose.bones for bone in bone_list: meshify(bone.head, bone.bone.head_radius, bone.tail, bone.bone.tail_radius, bone.parent is None) # Must be in OBJECT mode to create and link new mesh objects. if original_mode != 'OBJECT': bpy.ops.object.mode_set(mode='OBJECT') 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) # Make the new mesh active/selected while we are in OBJECT mode. bpy.ops.object.select_all(action='DESELECT') result_obj.select_set(True) context.view_layer.objects.active = result_obj # If we started in EDIT mode, restore it on the armature now. if original_mode == 'EDIT': result_obj.select_set(False) arm_obj.select_set(True) context.view_layer.objects.active = arm_obj bpy.ops.object.mode_set(mode='EDIT') 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 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()