caps, visual mode
This commit is contained in:
parent
ed5457ecc2
commit
101e8bc86b
320
__init__.py
320
__init__.py
@ -2,147 +2,185 @@
|
|||||||
Armature Mesher - Blender Addon
|
Armature Mesher - Blender Addon
|
||||||
Converts a selected armature into an envelope-style mesh using each bone's
|
Converts a selected armature into an envelope-style mesh using each bone's
|
||||||
head/tail radius values (exactly like Envelope display mode).
|
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 bpy
|
||||||
import bmesh
|
import bmesh
|
||||||
import math
|
import math
|
||||||
from mathutils import Vector, Matrix, Quaternion
|
from mathutils import Vector, Matrix
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Geometry helpers
|
# Geometry helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def make_sphere_bmesh(bm, center: Vector, radius: float, segments: int = 12) -> list:
|
def _rotation_matrix_to_axis(target_axis: Vector) -> Matrix:
|
||||||
"""Add a UV-sphere to an existing BMesh, return the new verts."""
|
"""Return a 4x4 rotation matrix that rotates Z → target_axis."""
|
||||||
verts = []
|
ax = target_axis.normalized()
|
||||||
rings = segments // 2
|
z = Vector((0.0, 0.0, 1.0))
|
||||||
for ring in range(rings + 1):
|
cross = z.cross(ax)
|
||||||
phi = math.pi * ring / rings # 0 … π
|
if cross.length < 1e-6:
|
||||||
for seg in range(segments):
|
if ax.z > 0:
|
||||||
theta = 2 * math.pi * seg / segments
|
return Matrix.Identity(4)
|
||||||
x = radius * math.sin(phi) * math.cos(theta)
|
else:
|
||||||
y = radius * math.sin(phi) * math.sin(theta)
|
return Matrix.Rotation(math.pi, 4, 'X')
|
||||||
z = radius * math.cos(phi)
|
angle = math.acos(max(-1.0, min(1.0, z.dot(ax))))
|
||||||
verts.append(bm.verts.new(center + Vector((x, y, z))))
|
return Matrix.Rotation(angle, 4, cross.normalized())
|
||||||
|
|
||||||
# Connect rings
|
|
||||||
for ring in range(rings):
|
def add_sphere(bm: bmesh.types.BMesh, center: Vector, radius: float, segments: int):
|
||||||
for seg in range(segments):
|
"""Add a closed UV-sphere to bm."""
|
||||||
nxt = (seg + 1) % segments
|
rings = max(segments // 2, 2)
|
||||||
v0 = verts[ring * segments + seg]
|
verts = []
|
||||||
v1 = verts[ring * segments + nxt]
|
|
||||||
v2 = verts[(ring + 1) * segments + nxt]
|
for r in range(rings + 1):
|
||||||
v3 = verts[(ring + 1) * segments + seg]
|
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:
|
try:
|
||||||
bm.faces.new((v0, v1, v2, v3))
|
bm.faces.new((v0, v1, v2, v3))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return verts
|
return verts
|
||||||
|
|
||||||
|
|
||||||
|
def add_capped_frustum(bm: bmesh.types.BMesh,
|
||||||
|
head_center: Vector, head_radius: float,
|
||||||
|
tail_center: Vector, tail_radius: float,
|
||||||
# ---------------------------------------------------------------------------
|
segments: int):
|
||||||
# Core build function
|
"""
|
||||||
# ---------------------------------------------------------------------------
|
Add a capped tapered cylinder between two sphere centers.
|
||||||
|
Rings are placed at the sphere equators (offset = radius along axis)
|
||||||
SEGMENTS = 16 # quality — increase for smoother result
|
so they sit flush with the sphere surfaces.
|
||||||
|
Each open end is closed with a triangle fan cap.
|
||||||
|
"""
|
||||||
def build_envelope_mesh(armature_obj: bpy.types.Object) -> bpy.types.Object:
|
axis = tail_center - head_center
|
||||||
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
|
length = axis.length
|
||||||
if length < 1e-6:
|
if length < 1e-6:
|
||||||
continue
|
return
|
||||||
|
|
||||||
ax = axis.normalized()
|
ax = axis.normalized()
|
||||||
|
rot = _rotation_matrix_to_axis(ax)
|
||||||
|
|
||||||
def ring_offset(sphere_r, ring_r):
|
# Place rings at the sphere equators along the bone axis
|
||||||
rr = min(ring_r, sphere_r)
|
head_ring_center = head_center + ax * head_radius
|
||||||
return math.sqrt(max(sphere_r ** 2 - rr ** 2, 0.0))
|
tail_ring_center = tail_center - ax * tail_radius
|
||||||
|
|
||||||
head_off = ring_offset(head_r, head_r)
|
# Skip if rings would overlap (bone too short relative to radii)
|
||||||
tail_off = ring_offset(tail_r, tail_r)
|
if (tail_ring_center - head_ring_center).dot(ax) < 1e-6:
|
||||||
|
return
|
||||||
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())
|
|
||||||
|
|
||||||
|
# Build the two rings
|
||||||
head_ring = []
|
head_ring = []
|
||||||
tail_ring = []
|
tail_ring = []
|
||||||
for i in range(SEGMENTS):
|
for i in range(segments):
|
||||||
theta = 2 * math.pi * i / SEGMENTS
|
theta = 2.0 * math.pi * i / segments
|
||||||
lv = Vector((math.cos(theta), math.sin(theta), 0.0))
|
local = Vector((math.cos(theta), math.sin(theta), 0.0))
|
||||||
rv = rot @ lv
|
# rot is 4x4; multiply then drop w component
|
||||||
head_ring.append(bm.verts.new(ring_head_pt + rv * head_r))
|
offset = (rot @ local.to_4d()).to_3d()
|
||||||
tail_ring.append(bm.verts.new(ring_tail_pt + rv * tail_r))
|
head_ring.append(bm.verts.new(head_ring_center + offset * head_radius))
|
||||||
|
tail_ring.append(bm.verts.new(tail_ring_center + offset * tail_radius))
|
||||||
|
|
||||||
for i in range(SEGMENTS):
|
# Side quads
|
||||||
nxt = (i + 1) % SEGMENTS
|
for i in range(segments):
|
||||||
|
nxt = (i + 1) % segments
|
||||||
try:
|
try:
|
||||||
bm.faces.new((head_ring[i], head_ring[nxt],
|
bm.faces.new((head_ring[i], head_ring[nxt],
|
||||||
tail_ring[nxt], tail_ring[i]))
|
tail_ring[nxt], tail_ring[i]))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Merge overlapping verts (where spheres from adjacent bones touch)
|
# Head cap — fan, winding faces inward (away from tail)
|
||||||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0001)
|
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
|
||||||
|
|
||||||
# Create mesh data-block
|
# Tail cap — fan, winding faces outward (away from head)
|
||||||
mesh = bpy.data.meshes.new(armature_obj.name + "_envelope_mesh")
|
tail_cap_v = bm.verts.new(tail_ring_center)
|
||||||
bm.to_mesh(mesh)
|
for i in range(segments):
|
||||||
bm.free()
|
nxt = (i + 1) % segments
|
||||||
|
try:
|
||||||
|
bm.faces.new((tail_cap_v, tail_ring[i], tail_ring[nxt]))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
mesh.update()
|
|
||||||
|
|
||||||
# Create object
|
# ---------------------------------------------------------------------------
|
||||||
obj = bpy.data.objects.new(armature_obj.name + "_envelope", mesh)
|
# Bone data collection — mode-aware
|
||||||
bpy.context.collection.objects.link(obj)
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
return obj
|
def collect_bone_data(arm_obj) -> list:
|
||||||
|
"""
|
||||||
|
Returns a list of dicts with world-space head/tail and radii.
|
||||||
|
Reads from the appropriate source based on the armature's current mode:
|
||||||
|
OBJECT → arm.bones (rest pose)
|
||||||
|
POSE → obj.pose.bones (current posed positions)
|
||||||
|
EDIT → arm.edit_bones (what you see in edit mode)
|
||||||
|
"""
|
||||||
|
mode = arm_obj.mode
|
||||||
|
world_mat = arm_obj.matrix_world
|
||||||
|
result = []
|
||||||
|
|
||||||
|
if mode == 'POSE':
|
||||||
|
for pb in arm_obj.pose.bones:
|
||||||
|
b = pb.bone # underlying data bone for radii
|
||||||
|
result.append({
|
||||||
|
'head': world_mat @ pb.head,
|
||||||
|
'tail': world_mat @ pb.tail,
|
||||||
|
'head_r': max(b.head_radius, 0.001),
|
||||||
|
'tail_r': max(b.tail_radius, 0.001),
|
||||||
|
})
|
||||||
|
|
||||||
|
elif mode == 'EDIT':
|
||||||
|
# edit_bones is accessible while in edit mode
|
||||||
|
for eb in arm_obj.data.edit_bones:
|
||||||
|
result.append({
|
||||||
|
'head': world_mat @ eb.head,
|
||||||
|
'tail': world_mat @ eb.tail,
|
||||||
|
'head_r': max(eb.head_radius, 0.001),
|
||||||
|
'tail_r': max(eb.tail_radius, 0.001),
|
||||||
|
})
|
||||||
|
|
||||||
|
else: # OBJECT / everything else → rest pose
|
||||||
|
for bone in arm_obj.data.bones:
|
||||||
|
result.append({
|
||||||
|
'head': world_mat @ bone.head_local,
|
||||||
|
'tail': world_mat @ bone.tail_local,
|
||||||
|
'head_r': max(bone.head_radius, 0.001),
|
||||||
|
'tail_r': max(bone.tail_radius, 0.001),
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -153,17 +191,15 @@ class ARMATURE_OT_build_envelope_mesh(bpy.types.Operator):
|
|||||||
bl_idname = "armature.build_envelope_mesh"
|
bl_idname = "armature.build_envelope_mesh"
|
||||||
bl_label = "Build Envelope Mesh"
|
bl_label = "Build Envelope Mesh"
|
||||||
bl_description = (
|
bl_description = (
|
||||||
"Convert the selected armature into a mesh that mirrors its "
|
"Convert the selected armature into an envelope-style mesh. "
|
||||||
"Envelope display (spheres at joints, tapered cylinders along bones)"
|
"Object mode = rest pose | Pose mode = current pose | Edit mode = edit bones."
|
||||||
)
|
)
|
||||||
bl_options = {'REGISTER', 'UNDO'}
|
bl_options = {'REGISTER', 'UNDO'}
|
||||||
|
|
||||||
segments: bpy.props.IntProperty(
|
segments: bpy.props.IntProperty(
|
||||||
name="Segments",
|
name="Segments",
|
||||||
description="Cylinder / sphere resolution",
|
description="Sphere / cylinder resolution",
|
||||||
default=16,
|
default=16, min=4, max=64,
|
||||||
min=4,
|
|
||||||
max=64,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -172,23 +208,59 @@ class ARMATURE_OT_build_envelope_mesh(bpy.types.Operator):
|
|||||||
return obj is not None and obj.type == 'ARMATURE'
|
return obj is not None and obj.type == 'ARMATURE'
|
||||||
|
|
||||||
def execute(self, context):
|
def execute(self, context):
|
||||||
global SEGMENTS
|
|
||||||
SEGMENTS = self.segments
|
|
||||||
|
|
||||||
arm_obj = context.active_object
|
arm_obj = context.active_object
|
||||||
|
original_mode = arm_obj.mode
|
||||||
|
|
||||||
# Ensure we have edit-mode bone data (rest-pose local coords)
|
# Snapshot bone data NOW, while we're still in the original mode.
|
||||||
if arm_obj.mode != 'OBJECT':
|
# This is especially important for EDIT mode where edit_bones are live.
|
||||||
|
bones_data = collect_bone_data(arm_obj)
|
||||||
|
|
||||||
|
# Must be in OBJECT mode to create and link new mesh objects.
|
||||||
|
if original_mode != 'OBJECT':
|
||||||
bpy.ops.object.mode_set(mode='OBJECT')
|
bpy.ops.object.mode_set(mode='OBJECT')
|
||||||
|
|
||||||
result_obj = build_envelope_mesh(arm_obj)
|
# Build the BMesh
|
||||||
|
bm = bmesh.new()
|
||||||
|
segs = self.segments
|
||||||
|
|
||||||
|
for bd in bones_data:
|
||||||
|
add_sphere(bm, bd['head'], bd['head_r'], segs)
|
||||||
|
add_sphere(bm, bd['tail'], bd['tail_r'], segs)
|
||||||
|
add_capped_frustum(bm,
|
||||||
|
bd['head'], bd['head_r'],
|
||||||
|
bd['tail'], bd['tail_r'],
|
||||||
|
segs)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
# Select the new mesh
|
|
||||||
bpy.ops.object.select_all(action='DESELECT')
|
bpy.ops.object.select_all(action='DESELECT')
|
||||||
result_obj.select_set(True)
|
result_obj.select_set(True)
|
||||||
context.view_layer.objects.active = result_obj
|
context.view_layer.objects.active = result_obj
|
||||||
|
|
||||||
self.report({'INFO'}, f"Created: {result_obj.name}")
|
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'}
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
|
||||||
@ -205,12 +277,19 @@ class VIEW3D_PT_armature_mesher(bpy.types.Panel):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def poll(cls, context):
|
def poll(cls, context):
|
||||||
return (context.active_object is not None and
|
obj = context.active_object
|
||||||
context.active_object.type == 'ARMATURE')
|
return obj is not None and obj.type == 'ARMATURE'
|
||||||
|
|
||||||
def draw(self, context):
|
def draw(self, context):
|
||||||
layout = self.layout
|
layout = self.layout
|
||||||
layout.label(text="Selected: " + context.active_object.name)
|
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()
|
layout.separator()
|
||||||
op = layout.operator(
|
op = layout.operator(
|
||||||
ARMATURE_OT_build_envelope_mesh.bl_idname,
|
ARMATURE_OT_build_envelope_mesh.bl_idname,
|
||||||
@ -229,16 +308,13 @@ classes = (
|
|||||||
VIEW3D_PT_armature_mesher,
|
VIEW3D_PT_armature_mesher,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
for cls in classes:
|
for cls in classes:
|
||||||
bpy.utils.register_class(cls)
|
bpy.utils.register_class(cls)
|
||||||
|
|
||||||
|
|
||||||
def unregister():
|
def unregister():
|
||||||
for cls in reversed(classes):
|
for cls in reversed(classes):
|
||||||
bpy.utils.unregister_class(cls)
|
bpy.utils.unregister_class(cls)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
register()
|
register()
|
||||||
Loading…
Reference in New Issue
Block a user