283 lines
9.5 KiB
Python
283 lines
9.5 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)
|
|
|
|
# Make the new mesh active/selected while we are in OBJECT mode.
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
result_obj.select_set(True)
|
|
context.view_layer.objects.active = result_obj
|
|
|
|
# If we started in EDIT mode, restore it on the armature now.
|
|
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"
|
|
|
|
# @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() |