armature_mesher/__init__.py
2026-02-28 08:13:38 -05:00

291 lines
9.7 KiB
Python

"""
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()