improved primitive building

This commit is contained in:
Seth Trowbridge 2026-02-28 10:27:29 -05:00
parent 54e1965998
commit 84fd407c5b

View File

@ -5,135 +5,148 @@ from mathutils import Vector, Matrix
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Geometry helpers # Canonical primitive builders (Z-up, origin at centre / base)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _rotation_matrix_to_axis(target_axis: Vector) -> Matrix: def _sphere_positions(radius: float, segments: int) -> list[Vector]:
"""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) rings = max(segments // 2, 2)
verts = [] out = []
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): for r in range(rings + 1):
phi = math.pi * r / rings phi = math.pi * r / rings
sin_phi = math.sin(phi) sp, cp = math.sin(phi), math.cos(phi)
cos_phi = math.cos(phi)
for s in range(segments): for s in range(segments):
theta = 2.0 * math.pi * s / segments theta = 2.0 * math.pi * s / segments
local = Vector(( out.append(Vector((radius * sp * math.cos(theta),
radius * sin_phi * math.cos(theta), radius * sp * math.sin(theta),
radius * sin_phi * math.sin(theta), radius * cp)))
radius * cos_phi, return out
))
if rot is not None:
offset = (rot @ local.to_4d()).to_3d()
else:
offset = local
v = bm.verts.new(center + offset)
verts.append(v)
def _ring_positions(radius: float, segments: int) -> list[Vector]:
"""Unit ring in the XY plane — the caller's matrix orients it."""
out = []
for i in range(segments):
theta = 2.0 * math.pi * i / segments
out.append(Vector((math.cos(theta) * radius,
math.sin(theta) * radius,
0.0)))
return out
def _apply(mat: Matrix, vecs: list[Vector]) -> list[Vector]:
return [(mat @ v.to_4d()).to_3d() for v in vecs]
# ---------------------------------------------------------------------------
# BMesh helpers
# ---------------------------------------------------------------------------
def add_sphere(bm: bmesh.types.BMesh, mat: Matrix, radius: float, segments: int):
"""Sphere built at origin/Z-up, then transformed by `mat`."""
rings = max(segments // 2, 2)
verts = [bm.verts.new(p) for p in _apply(mat, _sphere_positions(radius, segments))]
for r in range(rings): for r in range(rings):
for s in range(segments): for s in range(segments):
s_next = (s + 1) % segments sn = (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((verts[ r * segments + s],
verts[ r * segments + sn],
verts[(r + 1) * segments + sn],
verts[(r + 1) * segments + s]))
except ValueError: except ValueError:
pass pass
return verts
def add_capped_frustum(bm: bmesh.types.BMesh, def add_capped_frustum(bm: bmesh.types.BMesh,
head_center: Vector, head_radius: float, head_mat: Matrix, head_radius: float,
tail_center: Vector, tail_radius: float, tail_mat: Matrix, tail_radius: float,
segments: int): segments: int):
""" """
Add a capped tapered cylinder between two sphere centers. Tapered cylinder. Each ring is built canonically in XY then placed by
Rings are placed at the sphere equators (offset = radius along axis) the per-end matrix no axis arithmetic at all.
so they sit flush with the sphere surfaces. head_mat / tail_mat each encode: orientation (bone axes) + translation
Each open end is closed with a triangle fan cap. (the head or tail world position). The ring normal naturally aligns with
the bone axis because the matrix was built that way.
""" """
axis = tail_center - head_center head_pos = (head_mat @ Vector((0, 0, 0, 1))).to_3d()
length = axis.length tail_pos = (tail_mat @ Vector((0, 0, 0, 1))).to_3d()
if length < 1e-6: if (tail_pos - head_pos).length < 1e-6:
return return
ax = axis.normalized() hr = [bm.verts.new(p) for p in _apply(head_mat, _ring_positions(head_radius, segments))]
rot = _rotation_matrix_to_axis(ax) tr = [bm.verts.new(p) for p in _apply(tail_mat, _ring_positions(tail_radius, segments))]
# 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): for i in range(segments):
nxt = (i + 1) % segments nxt = (i + 1) % segments
try: try:
bm.faces.new((head_ring[i], head_ring[nxt], bm.faces.new((hr[i], hr[nxt], tr[nxt], tr[i]))
tail_ring[nxt], tail_ring[i]))
except ValueError: except ValueError:
pass pass
# Head cap — fan, winding faces inward (away from tail) hcv = bm.verts.new(head_pos)
head_cap_v = bm.verts.new(head_ring_center)
for i in range(segments): for i in range(segments):
nxt = (i + 1) % segments
try: try:
bm.faces.new((head_cap_v, head_ring[nxt], head_ring[i])) bm.faces.new((hcv, hr[(i + 1) % segments], hr[i]))
except ValueError: except ValueError:
pass pass
# Tail cap — fan, winding faces outward (away from head) tcv = bm.verts.new(tail_pos)
tail_cap_v = bm.verts.new(tail_ring_center)
for i in range(segments): for i in range(segments):
nxt = (i + 1) % segments
try: try:
bm.faces.new((tail_cap_v, tail_ring[i], tail_ring[nxt])) bm.faces.new((tcv, tr[i], tr[(i + 1) % segments]))
except ValueError: except ValueError:
pass pass
# ---------------------------------------------------------------------------
# Bone → per-end matrices
# ---------------------------------------------------------------------------
def _end_matrices(world_mat: Matrix, bone_matrix: Matrix, bone_length: float):
"""
Return (head_mat, tail_mat) 4x4 world-space matrices for each bone end.
The bone matrix (armature-local) has:
col[0] = bone X axis
col[1] = bone Y axis along the bone
col[2] = bone Z axis
col[3] = head position (armature local)
We want each end's matrix to have:
our X = bone X spans the cross-section ring
our Y = bone Z spans the cross-section ring
our Z = bone Y ring normal points along bone
translation = head or tail world position
This means _ring_positions() (which lies in XY, normal=Z) will be
perpendicular to the bone with zero trigonometry.
"""
bx = bone_matrix.col[0].to_3d()
by = bone_matrix.col[1].to_3d() # along bone
bz = bone_matrix.col[2].to_3d()
# 3x3 orientation: columns = [our_X, our_Y, our_Z] = [bx, bz, by]
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):
m = orient.to_4x4()
world_pos = (world_mat @ pos_local.to_4d()).to_3d()
# orientation columns also need the world rotation/scale applied
# Easiest: build full local matrix then left-multiply by world_mat.
local_mat = orient.to_4x4()
local_mat.col[3][:3] = pos_local # set translation in armature space
return world_mat @ local_mat
return _make(head_local), _make(tail_local)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Operator # Operator
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -143,7 +156,7 @@ class ARMATURE_OT_build_envelope_mesh(bpy.types.Operator):
bl_label = "Build Envelope Mesh" bl_label = "Build Envelope Mesh"
bl_description = ( bl_description = (
"Convert the selected armature into an envelope-style mesh. " "Convert the selected armature into an envelope-style mesh. "
"Object mode = rest pose | Pose mode = current pose | Edit mode = edit bones." "Object/Pose mode = posed bones | Edit mode = edit bones."
) )
bl_options = {'REGISTER', 'UNDO'} bl_options = {'REGISTER', 'UNDO'}
@ -161,48 +174,30 @@ class ARMATURE_OT_build_envelope_mesh(bpy.types.Operator):
def execute(self, context): def execute(self, context):
arm_obj = context.active_object arm_obj = context.active_object
original_mode = arm_obj.mode original_mode = arm_obj.mode
world_mat = arm_obj.matrix_world
bm = bmesh.new()
segs = self.segments
# Snapshot bone data NOW, while we're still in the original mode. def meshify(bone_matrix, bone_length, head_radius, tail_radius, draw_head=True):
# This is especially important for EDIT mode where edit_bones are live. head_mat, tail_mat = _end_matrices(world_mat, bone_matrix, bone_length)
# bones_data = collect_bone_data(arm_obj) // we arent doing this anymore axis_vec = (tail_mat @ Vector((0,0,0,1))).to_3d() - (head_mat @ Vector((0,0,0,1))).to_3d()
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: if draw_head:
add_sphere(bm, head_matrix_final, head_radius_final, segs, axis=axis_vec) add_sphere(bm, head_mat, max(head_radius, 0.001), segs)
add_sphere(bm, tail_matrix_final, tail_radius_final, segs, axis=axis_vec) add_sphere(bm, tail_mat, max(tail_radius, 0.001), segs)
add_capped_frustum(bm, add_capped_frustum(bm, head_mat, max(head_radius, 0.001),
head_matrix_final, head_radius_final, tail_mat, max(tail_radius, 0.001), segs)
tail_matrix_final, tail_radius_final,
segs)
if original_mode == 'EDIT':
# pose mode or object mode: for bone in arm_obj.data.edit_bones:
if mode == "EDIT": meshify(bone.matrix, bone.length,
bone_list = arm_obj.data.edit_bones bone.head_radius, bone.tail_radius,
for bone in bone_list: bone.parent is None)
meshify(bone.head, bone.head_radius, bone.tail, bone.tail_radius, bone.parent is None)
else: else:
bone_list = arm_obj.pose.bones for bone in arm_obj.pose.bones:
for bone in bone_list: meshify(bone.matrix, bone.bone.length,
meshify(bone.head, bone.bone.head_radius, bone.tail, bone.bone.tail_radius, bone.parent is None) bone.bone.head_radius, bone.bone.tail_radius,
bone.parent is None)
# Must be in OBJECT mode to create and link new mesh objects.
if original_mode != 'OBJECT': if original_mode != 'OBJECT':
bpy.ops.object.mode_set(mode='OBJECT') bpy.ops.object.mode_set(mode='OBJECT')
@ -214,24 +209,17 @@ class ARMATURE_OT_build_envelope_mesh(bpy.types.Operator):
result_obj = bpy.data.objects.new(arm_obj.name + "_envelope", mesh) result_obj = bpy.data.objects.new(arm_obj.name + "_envelope", mesh)
context.collection.objects.link(result_obj) 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') 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
# If we started in EDIT mode, restore it on the armature now.
if original_mode == 'EDIT': if original_mode == 'EDIT':
result_obj.select_set(False) result_obj.select_set(False)
arm_obj.select_set(True) arm_obj.select_set(True)
context.view_layer.objects.active = arm_obj context.view_layer.objects.active = arm_obj
bpy.ops.object.mode_set(mode='EDIT') bpy.ops.object.mode_set(mode='EDIT')
source_label = { source_label = {'OBJECT': "rest pose", 'POSE': "current pose", 'EDIT': "edit-bone layout"}.get(original_mode, original_mode)
'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}.") self.report({'INFO'}, f"Created '{result_obj.name}' from {source_label}.")
return {'FINISHED'} return {'FINISHED'}
@ -247,18 +235,10 @@ class VIEW3D_PT_armature_mesher(bpy.types.Panel):
bl_region_type = 'UI' bl_region_type = 'UI'
bl_category = "Armature" 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): def draw(self, context):
layout = self.layout layout = self.layout
op = layout.operator( op = layout.operator(ARMATURE_OT_build_envelope_mesh.bl_idname,
ARMATURE_OT_build_envelope_mesh.bl_idname, text="Build Envelope Mesh", icon='OUTLINER_OB_MESH')
text="Build Envelope Mesh",
icon='OUTLINER_OB_MESH',
)
op.segments = 16 op.segments = 16
@ -266,18 +246,13 @@ class VIEW3D_PT_armature_mesher(bpy.types.Panel):
# Registration # Registration
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
classes = ( classes = (ARMATURE_OT_build_envelope_mesh, VIEW3D_PT_armature_mesher)
ARMATURE_OT_build_envelope_mesh,
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()