armature_mesher/__init__.py
2026-02-28 10:03:45 -05:00

271 lines
9.0 KiB
Python

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