commit ed5457ecc2b10012a76e39c40aab1fda3b7a0f12 Author: Seth Trowbridge Date: Sat Feb 28 06:58:47 2026 -0500 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..643dbc0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/* +.blender_ext/* +*.pyc +*.zip \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..23692d1 --- /dev/null +++ b/__init__.py @@ -0,0 +1,244 @@ +""" +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() \ No newline at end of file diff --git a/blender_manifest.toml b/blender_manifest.toml new file mode 100644 index 0000000..32f31eb --- /dev/null +++ b/blender_manifest.toml @@ -0,0 +1,13 @@ +schema_version = "1.0.0" + +id = "armature_mesher" +version = "1.0.0" +name = "Armature Mesher" +tagline = "Convert armature to envelope-style mesh" +maintainer = "You" +type = "add-on" + +blender_version_min = "5.1.0" + +license = ["SPDX:GPL-2.0-or-later"] +category = "Rigging" \ No newline at end of file