armature_mesher/__init__.py

308 lines
10 KiB
Python

import bpy
import bmesh
import math
from mathutils import Vector, Matrix
# ---------------------------------------------------------------------------
# Canonical primitive builders (Z-up, origin at centre)
# ---------------------------------------------------------------------------
class _Tables:
"""Precomputed trig tables for a given segment count."""
def __init__(self, segments: int):
self.segments = segments
segScalar = 2 * math.pi / segments
self.circle = [
(math.cos(segScalar * i), math.sin(segScalar * i)) for i in range(segments)
]
self.rings = max(segments // 2, 2)
ringScalar = math.pi / self.rings
self.latitudes = [
(math.sin(ringScalar * r), math.cos(ringScalar * r)) for r in range(self.rings + 1)
]
def _sphere_positions(radius: float, tables: _Tables) -> list:
out = []
for sin_phi, cos_phi in tables.latitudes:
for c, s in tables.circle:
out.append(Vector((
radius * sin_phi * c,
radius * sin_phi * s,
radius * cos_phi,
)))
return out
def _ring_positions(radius: float, tables: _Tables) -> list:
"""Flat ring in the XY plane — caller's matrix orients it."""
return [Vector((c * radius, s * radius, 0.0)) for c, s in tables.circle]
def _apply(mat: Matrix, vecs: list) -> list:
return [(mat @ v.to_4d()).to_3d() for v in vecs]
# ---------------------------------------------------------------------------
# BMesh helpers
# ---------------------------------------------------------------------------
def add_sphere(bm: bmesh.types.BMesh, mat: Matrix, radius: float, tables: _Tables):
"""Sphere built at origin/Z-up, then placed by `mat`."""
segs = tables.segments
rings = tables.rings
verts = [bm.verts.new(p) for p in _apply(mat, _sphere_positions(radius, tables))]
for r in range(rings):
for s in range(segs):
sn = (s + 1) % segs
try:
bm.faces.new((
verts[ r * segs + s ],
verts[ r * segs + sn],
verts[(r + 1) * segs + sn],
verts[(r + 1) * segs + s ],
))
except ValueError:
pass
def add_capped_frustum(bm: bmesh.types.BMesh,
head_mat: Matrix, head_radius: float,
tail_mat: Matrix, tail_radius: float,
tables: _Tables):
"""
Tapered cylinder. Rings are built in XY then placed by per-end matrices —
no axis arithmetic needed.
"""
head_pos = (head_mat @ Vector((0, 0, 0, 1))).to_3d()
tail_pos = (tail_mat @ Vector((0, 0, 0, 1))).to_3d()
if (tail_pos - head_pos).length < 1e-6:
return
segs = tables.segments
hr = [bm.verts.new(p) for p in _apply(head_mat, _ring_positions(head_radius, tables))]
tr = [bm.verts.new(p) for p in _apply(tail_mat, _ring_positions(tail_radius, tables))]
for i in range(segs):
nxt = (i + 1) % segs
try:
bm.faces.new((tr[i], tr[nxt], hr[nxt], hr[i]))
except ValueError:
pass
hcv = bm.verts.new(head_pos)
for i in range(segs):
try:
bm.faces.new((hcv, hr[i], hr[(i + 1) % segs]))
except ValueError:
pass
tcv = bm.verts.new(tail_pos)
for i in range(segs):
try:
bm.faces.new((tcv, tr[(i + 1) % segs], tr[i]))
except ValueError:
pass
# ---------------------------------------------------------------------------
# Bone → per-end matrices
# ---------------------------------------------------------------------------
def _end_matrices(bone_matrix: Matrix, bone_length: float):
"""
Return (head_mat, tail_mat) — world-space matrices for each bone end.
The bone matrix (armature-local) columns:
col[0] = bone X axis
col[1] = bone Y axis ← along the bone
col[2] = bone Z axis
col[3] = head position
We remap columns so our local Z = bone Y, meaning _ring_positions()
(which lies in XY, normal = +Z) is automatically perpendicular to the
bone — no cross products or acos required.
our X = bone X
our Y = bone Z
our Z = bone Y (ring normal → along bone)
"""
bx = bone_matrix.col[0].to_3d()
by = bone_matrix.col[1].to_3d()
bz = bone_matrix.col[2].to_3d()
orient = Matrix((
(bx.x, bz.x, by.x),
(bx.y, bz.y, by.y),
(bx.z, bz.z, by.z),
))
head_local = bone_matrix.col[3].to_3d()
tail_local = head_local + by * bone_length
def _make(pos_local):
local_mat = orient.to_4x4()
local_mat.col[3][:3] = pos_local
return local_mat
return _make(head_local), _make(tail_local)
SUFFIX = "_envelope_mesh"
# ---------------------------------------------------------------------------
# Operators
# ---------------------------------------------------------------------------
class ARMATURE_OT_release_mesh(bpy.types.Operator):
bl_idname = "armature.release_mesh"
bl_label = "Release Mesh"
bl_description = (
"Clear the target mesh reference from the panel. "
"Does not delete any objects or data, but allows the next build to create a new mesh."
)
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
obj = context.active_object
if obj is not None and obj.type == 'ARMATURE':
lookup_name = obj.name + SUFFIX
for child in obj.children:
if child.type == 'MESH' and child.name == lookup_name:
return True
def execute(self, context):
obj = context.active_object
for child in obj.children:
if child.type == 'MESH' and child.name == obj.name + SUFFIX:
m = child.matrix_world.copy()
child.parent = None
child.matrix_world = m
child.name = child.name.replace(SUFFIX, "_snapshot")
return {'FINISHED'}
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. "
"If a target object is set in the panel, its mesh is replaced in place. "
"Otherwise a new object is created and assigned as the target."
)
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
bm = bmesh.new()
tables = _Tables(self.segments)
def meshify(bone_matrix, bone_length, head_radius, tail_radius, draw_head=True):
head_mat, tail_mat = _end_matrices(bone_matrix, bone_length)
if draw_head:
add_sphere(bm, head_mat, max(head_radius, 0.001), tables)
add_sphere(bm, tail_mat, max(tail_radius, 0.001), tables)
add_capped_frustum(bm,
head_mat, max(head_radius, 0.001),
tail_mat, max(tail_radius, 0.001),
tables)
if arm_obj.mode == 'EDIT':
for bone in arm_obj.data.edit_bones:
meshify(bone.matrix, bone.length,
bone.head_radius, bone.tail_radius,
bone.parent is None)
else:
for bone in arm_obj.pose.bones:
meshify(bone.matrix, bone.bone.length,
bone.bone.head_radius, bone.bone.tail_radius,
bone.parent is None)
lookup_name = arm_obj.name + SUFFIX
target = None
for child in arm_obj.children:
if child.type == 'MESH' and child.name == lookup_name:
target = child
break
print(target)
if target is not None:
# Destructively replace the target's mesh data.
old_mesh = target.data
new_mesh = bpy.data.meshes.new(old_mesh.name)
bm.to_mesh(new_mesh)
bm.free()
new_mesh.update()
target.data = new_mesh
bpy.data.meshes.remove(old_mesh)
else:
# Create a new object and hand it back to the panel picker.
new_mesh = bpy.data.meshes.new(lookup_name)
bm.to_mesh(new_mesh)
bm.free()
new_mesh.update()
result_obj = bpy.data.objects.new(lookup_name, new_mesh)
result_obj.parent = arm_obj
context.collection.objects.link(result_obj)
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"
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
op = layout.operator(
ARMATURE_OT_release_mesh.bl_idname,
text="Release Mesh",
icon='OUTLINER_OB_MESH',
)
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
classes = (
ARMATURE_OT_release_mesh,
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()