init
This commit is contained in:
commit
ed5457ecc2
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
__pycache__/*
|
||||
.blender_ext/*
|
||||
*.pyc
|
||||
*.zip
|
||||
244
__init__.py
Normal file
244
__init__.py
Normal file
@ -0,0 +1,244 @@
|
||||
"""
|
||||
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).
|
||||
"""
|
||||
|
||||
import bpy
|
||||
import bmesh
|
||||
import math
|
||||
from mathutils import Vector, Matrix, Quaternion
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Geometry helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_sphere_bmesh(bm, center: Vector, radius: float, segments: int = 12) -> list:
|
||||
"""Add a UV-sphere to an existing BMesh, return the new verts."""
|
||||
verts = []
|
||||
rings = segments // 2
|
||||
for ring in range(rings + 1):
|
||||
phi = math.pi * ring / rings # 0 … π
|
||||
for seg in range(segments):
|
||||
theta = 2 * math.pi * seg / segments
|
||||
x = radius * math.sin(phi) * math.cos(theta)
|
||||
y = radius * math.sin(phi) * math.sin(theta)
|
||||
z = radius * math.cos(phi)
|
||||
verts.append(bm.verts.new(center + Vector((x, y, z))))
|
||||
|
||||
# Connect rings
|
||||
for ring in range(rings):
|
||||
for seg in range(segments):
|
||||
nxt = (seg + 1) % segments
|
||||
v0 = verts[ring * segments + seg]
|
||||
v1 = verts[ring * segments + nxt]
|
||||
v2 = verts[(ring + 1) * segments + nxt]
|
||||
v3 = verts[(ring + 1) * segments + seg]
|
||||
try:
|
||||
bm.faces.new((v0, v1, v2, v3))
|
||||
except ValueError:
|
||||
pass
|
||||
return verts
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core build function
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SEGMENTS = 16 # quality — increase for smoother result
|
||||
|
||||
|
||||
def build_envelope_mesh(armature_obj: bpy.types.Object) -> bpy.types.Object:
|
||||
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
|
||||
if length < 1e-6:
|
||||
continue
|
||||
|
||||
ax = axis.normalized()
|
||||
|
||||
def ring_offset(sphere_r, ring_r):
|
||||
rr = min(ring_r, sphere_r)
|
||||
return math.sqrt(max(sphere_r ** 2 - rr ** 2, 0.0))
|
||||
|
||||
head_off = ring_offset(head_r, head_r)
|
||||
tail_off = ring_offset(tail_r, tail_r)
|
||||
|
||||
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())
|
||||
|
||||
head_ring = []
|
||||
tail_ring = []
|
||||
for i in range(SEGMENTS):
|
||||
theta = 2 * math.pi * i / SEGMENTS
|
||||
lv = Vector((math.cos(theta), math.sin(theta), 0.0))
|
||||
rv = rot @ lv
|
||||
head_ring.append(bm.verts.new(ring_head_pt + rv * head_r))
|
||||
tail_ring.append(bm.verts.new(ring_tail_pt + rv * tail_r))
|
||||
|
||||
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
|
||||
|
||||
# Merge overlapping verts (where spheres from adjacent bones touch)
|
||||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0001)
|
||||
|
||||
# Create mesh data-block
|
||||
mesh = bpy.data.meshes.new(armature_obj.name + "_envelope_mesh")
|
||||
bm.to_mesh(mesh)
|
||||
bm.free()
|
||||
|
||||
mesh.update()
|
||||
|
||||
# Create object
|
||||
obj = bpy.data.objects.new(armature_obj.name + "_envelope", mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 a mesh that mirrors its "
|
||||
"Envelope display (spheres at joints, tapered cylinders along bones)"
|
||||
)
|
||||
bl_options = {'REGISTER', 'UNDO'}
|
||||
|
||||
segments: bpy.props.IntProperty(
|
||||
name="Segments",
|
||||
description="Cylinder / sphere 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):
|
||||
global SEGMENTS
|
||||
SEGMENTS = self.segments
|
||||
|
||||
arm_obj = context.active_object
|
||||
|
||||
# Ensure we have edit-mode bone data (rest-pose local coords)
|
||||
if arm_obj.mode != 'OBJECT':
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
result_obj = build_envelope_mesh(arm_obj)
|
||||
|
||||
# Select the new mesh
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
result_obj.select_set(True)
|
||||
context.view_layer.objects.active = result_obj
|
||||
|
||||
self.report({'INFO'}, f"Created: {result_obj.name}")
|
||||
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):
|
||||
return (context.active_object is not None and
|
||||
context.active_object.type == 'ARMATURE')
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.label(text="Selected: " + context.active_object.name)
|
||||
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()
|
||||
13
blender_manifest.toml
Normal file
13
blender_manifest.toml
Normal file
@ -0,0 +1,13 @@
|
||||
schema_version = "1.0.0"
|
||||
|
||||
id = "armature_mesher"
|
||||
version = "1.0.0"
|
||||
name = "Armature Mesher"
|
||||
tagline = "Convert armature to envelope-style mesh"
|
||||
maintainer = "You"
|
||||
type = "add-on"
|
||||
|
||||
blender_version_min = "5.1.0"
|
||||
|
||||
license = ["SPDX:GPL-2.0-or-later"]
|
||||
category = "Rigging"
|
||||
Loading…
Reference in New Issue
Block a user