import bpy import bmesh import math from mathutils import Vector, Matrix # --------------------------------------------------------------------------- # Canonical primitive builders (Z-up, origin at centre / base) # --------------------------------------------------------------------------- def _sphere_positions(radius: float, segments: int) -> list[Vector]: rings = max(segments // 2, 2) out = [] for r in range(rings + 1): phi = math.pi * r / rings sp, cp = math.sin(phi), math.cos(phi) for s in range(segments): theta = 2.0 * math.pi * s / segments out.append(Vector((radius * sp * math.cos(theta), radius * sp * math.sin(theta), radius * cp))) return out def _ring_positions(radius: float, segments: int) -> list[Vector]: """Unit ring in the XY plane — the caller's matrix orients it.""" out = [] for i in range(segments): theta = 2.0 * math.pi * i / segments out.append(Vector((math.cos(theta) * radius, math.sin(theta) * radius, 0.0))) return out def _apply(mat: Matrix, vecs: list[Vector]) -> list[Vector]: return [(mat @ v.to_4d()).to_3d() for v in vecs] # --------------------------------------------------------------------------- # BMesh helpers # --------------------------------------------------------------------------- def add_sphere(bm: bmesh.types.BMesh, mat: Matrix, radius: float, segments: int): """Sphere built at origin/Z-up, then transformed by `mat`.""" rings = max(segments // 2, 2) verts = [bm.verts.new(p) for p in _apply(mat, _sphere_positions(radius, segments))] for r in range(rings): for s in range(segments): sn = (s + 1) % segments try: bm.faces.new((verts[ r * segments + s], verts[ r * segments + sn], verts[(r + 1) * segments + sn], verts[(r + 1) * segments + s])) except ValueError: pass def add_capped_frustum(bm: bmesh.types.BMesh, head_mat: Matrix, head_radius: float, tail_mat: Matrix, tail_radius: float, segments: int): """ Tapered cylinder. Each ring is built canonically in XY then placed by the per-end matrix — no axis arithmetic at all. head_mat / tail_mat each encode: orientation (bone axes) + translation (the head or tail world position). The ring normal naturally aligns with the bone axis because the matrix was built that way. """ head_pos = (head_mat @ Vector((0, 0, 0, 1))).to_3d() tail_pos = (tail_mat @ Vector((0, 0, 0, 1))).to_3d() if (tail_pos - head_pos).length < 1e-6: return hr = [bm.verts.new(p) for p in _apply(head_mat, _ring_positions(head_radius, segments))] tr = [bm.verts.new(p) for p in _apply(tail_mat, _ring_positions(tail_radius, segments))] for i in range(segments): nxt = (i + 1) % segments try: bm.faces.new((hr[i], hr[nxt], tr[nxt], tr[i])) except ValueError: pass hcv = bm.verts.new(head_pos) for i in range(segments): try: bm.faces.new((hcv, hr[(i + 1) % segments], hr[i])) except ValueError: pass tcv = bm.verts.new(tail_pos) for i in range(segments): try: bm.faces.new((tcv, tr[i], tr[(i + 1) % segments])) except ValueError: pass # --------------------------------------------------------------------------- # Bone → per-end matrices # --------------------------------------------------------------------------- def _end_matrices(world_mat: Matrix, bone_matrix: Matrix, bone_length: float): """ Return (head_mat, tail_mat) — 4x4 world-space matrices for each bone end. The bone matrix (armature-local) has: col[0] = bone X axis col[1] = bone Y axis ← along the bone col[2] = bone Z axis col[3] = head position (armature local) We want each end's matrix to have: our X = bone X ← spans the cross-section ring our Y = bone Z ← spans the cross-section ring our Z = bone Y ← ring normal points along bone translation = head or tail world position This means _ring_positions() (which lies in XY, normal=Z) will be perpendicular to the bone with zero trigonometry. """ bx = bone_matrix.col[0].to_3d() by = bone_matrix.col[1].to_3d() # along bone bz = bone_matrix.col[2].to_3d() # 3x3 orientation: columns = [our_X, our_Y, our_Z] = [bx, bz, by] orient = Matrix(( (bx.x, bz.x, by.x), (bx.y, bz.y, by.y), (bx.z, bz.z, by.z), )) head_local = bone_matrix.col[3].to_3d() tail_local = head_local + by * bone_length def _make(pos_local): m = orient.to_4x4() world_pos = (world_mat @ pos_local.to_4d()).to_3d() # orientation columns also need the world rotation/scale applied # Easiest: build full local matrix then left-multiply by world_mat. local_mat = orient.to_4x4() local_mat.col[3][:3] = pos_local # set translation in armature space return world_mat @ local_mat return _make(head_local), _make(tail_local) # --------------------------------------------------------------------------- # 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/Pose mode = posed bones | 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 world_mat = arm_obj.matrix_world bm = bmesh.new() segs = self.segments def meshify(bone_matrix, bone_length, head_radius, tail_radius, draw_head=True): head_mat, tail_mat = _end_matrices(world_mat, bone_matrix, bone_length) axis_vec = (tail_mat @ Vector((0,0,0,1))).to_3d() - (head_mat @ Vector((0,0,0,1))).to_3d() if draw_head: add_sphere(bm, head_mat, max(head_radius, 0.001), segs) add_sphere(bm, tail_mat, max(tail_radius, 0.001), segs) add_capped_frustum(bm, head_mat, max(head_radius, 0.001), tail_mat, max(tail_radius, 0.001), segs) if original_mode == 'EDIT': for bone in arm_obj.data.edit_bones: meshify(bone.matrix, bone.length, bone.head_radius, bone.tail_radius, bone.parent is None) else: for bone in arm_obj.pose.bones: meshify(bone.matrix, bone.bone.length, bone.bone.head_radius, bone.bone.tail_radius, bone.parent is None) 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) bpy.ops.object.select_all(action='DESELECT') result_obj.select_set(True) context.view_layer.objects.active = result_obj 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" 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()