This commit is contained in:
Seth Trowbridge 2026-02-28 10:03:45 -05:00
parent 97d294e559
commit f3036dcc29

View File

@ -1,18 +1,3 @@
"""
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
@ -37,22 +22,36 @@ def _rotation_matrix_to_axis(target_axis: Vector) -> Matrix:
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."""
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
v = bm.verts.new(center + Vector((
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):
@ -89,8 +88,8 @@ def add_capped_frustum(bm: bmesh.types.BMesh,
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
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:
@ -176,35 +175,37 @@ class ARMATURE_OT_build_envelope_mesh(bpy.types.Operator):
def parts(mat, rad):
return (world_mat @ mat, max(rad, 0.001))
def meshify(head_matrix, head_radius, tail_matrix, tail_radius):
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)
add_sphere(bm, head_matrix_final, head_radius_final, segs)
add_sphere(bm, tail_matrix_final, tail_radius_final, segs)
# 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)
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)
# 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')
# 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()
@ -213,18 +214,6 @@ class ARMATURE_OT_build_envelope_mesh(bpy.types.Operator):
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",
@ -246,22 +235,13 @@ class VIEW3D_PT_armature_mesher(bpy.types.Panel):
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'
# @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",