highly improved primitive building

This commit is contained in:
Seth Trowbridge 2026-02-28 10:41:05 -05:00
parent 84fd407c5b
commit 606df32fcb

View File

@ -5,35 +5,59 @@ from mathutils import Vector, Matrix
# ---------------------------------------------------------------------------
# Canonical primitive builders (Z-up, origin at centre / base)
# Lookup tables
# ---------------------------------------------------------------------------
def _sphere_positions(radius: float, segments: int) -> list[Vector]:
def _build_unit_circle(segments: int) -> list:
"""(cos, sin) sampled once per segment. Reused by all primitives."""
return [
(math.cos(2 * math.pi * i / segments),
math.sin(2 * math.pi * i / segments))
for i in range(segments)
]
def _build_latitude_stack(segments: int) -> list:
"""(sin_phi, cos_phi) for each latitude ring, poles included."""
rings = max(segments // 2, 2)
return [
(math.sin(math.pi * r / rings),
math.cos(math.pi * r / rings))
for r in range(rings + 1)
]
class _Tables:
"""Precomputed trig tables for a given segment count."""
def __init__(self, segments: int):
self.segments = segments
self.rings = max(segments // 2, 2)
self.circle = _build_unit_circle(segments)
self.latitudes = _build_latitude_stack(segments)
# ---------------------------------------------------------------------------
# Canonical primitive builders (Z-up, origin at centre)
# ---------------------------------------------------------------------------
def _sphere_positions(radius: float, tables: _Tables) -> list:
out = []
for r in range(rings + 1):
phi = math.pi * r / rings
sp, cp = math.sin(phi), math.cos(phi)
for s in range(segments):
theta = 2.0 * math.pi * s / segments
out.append(Vector((radius * sp * math.cos(theta),
radius * sp * math.sin(theta),
radius * cp)))
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, 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 _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[Vector]) -> list[Vector]:
def _apply(mat: Matrix, vecs: list) -> list:
return [(mat @ v.to_4d()).to_3d() for v in vecs]
@ -41,18 +65,21 @@ def _apply(mat: Matrix, vecs: list[Vector]) -> list[Vector]:
# 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))]
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(segments):
sn = (s + 1) % segments
for s in range(segs):
sn = (s + 1) % segs
try:
bm.faces.new((verts[ r * segments + s],
verts[ r * segments + sn],
verts[(r + 1) * segments + sn],
verts[(r + 1) * segments + s]))
bm.faces.new((
verts[ r * segs + s ],
verts[ r * segs + sn],
verts[(r + 1) * segs + sn],
verts[(r + 1) * segs + s ],
))
except ValueError:
pass
@ -60,40 +87,41 @@ def add_sphere(bm: bmesh.types.BMesh, mat: Matrix, radius: float, segments: int)
def add_capped_frustum(bm: bmesh.types.BMesh,
head_mat: Matrix, head_radius: float,
tail_mat: Matrix, tail_radius: float,
segments: int):
tables: _Tables):
"""
Tapered cylinder. Each ring is built canonically in XY then placed by
the per-end matrix no axis arithmetic at all.
head_mat / tail_mat each encode: orientation (bone axes) + translation
(the head or tail world position). The ring normal naturally aligns with
the bone axis because the matrix was built that way.
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
hr = [bm.verts.new(p) for p in _apply(head_mat, _ring_positions(head_radius, segments))]
tr = [bm.verts.new(p) for p in _apply(tail_mat, _ring_positions(tail_radius, segments))]
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(segments):
nxt = (i + 1) % segments
# Side quads
for i in range(segs):
nxt = (i + 1) % segs
try:
bm.faces.new((hr[i], hr[nxt], tr[nxt], tr[i]))
except ValueError:
pass
# Head cap
hcv = bm.verts.new(head_pos)
for i in range(segments):
for i in range(segs):
try:
bm.faces.new((hcv, hr[(i + 1) % segments], hr[i]))
bm.faces.new((hcv, hr[(i + 1) % segs], hr[i]))
except ValueError:
pass
# Tail cap
tcv = bm.verts.new(tail_pos)
for i in range(segments):
for i in range(segs):
try:
bm.faces.new((tcv, tr[i], tr[(i + 1) % segments]))
bm.faces.new((tcv, tr[i], tr[(i + 1) % segs]))
except ValueError:
pass
@ -104,28 +132,25 @@ def add_capped_frustum(bm: bmesh.types.BMesh,
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.
Return (head_mat, tail_mat) world-space matrices for each bone end.
The bone matrix (armature-local) has:
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 (armature local)
col[3] = head position
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.
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() # 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),
@ -136,12 +161,8 @@ def _end_matrices(world_mat: Matrix, bone_matrix: Matrix, bone_length: float):
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
local_mat.col[3][:3] = pos_local
return world_mat @ local_mat
return _make(head_local), _make(tail_local)
@ -173,21 +194,21 @@ class ARMATURE_OT_build_envelope_mesh(bpy.types.Operator):
def execute(self, context):
arm_obj = context.active_object
original_mode = arm_obj.mode
world_mat = arm_obj.matrix_world
bm = bmesh.new()
segs = self.segments
tables = _Tables(self.segments)
def meshify(bone_matrix, bone_length, head_radius, tail_radius, draw_head=True):
head_mat, tail_mat = _end_matrices(world_mat, bone_matrix, bone_length)
axis_vec = (tail_mat @ Vector((0,0,0,1))).to_3d() - (head_mat @ Vector((0,0,0,1))).to_3d()
if draw_head:
add_sphere(bm, head_mat, max(head_radius, 0.001), segs)
add_sphere(bm, tail_mat, max(tail_radius, 0.001), segs)
add_capped_frustum(bm, head_mat, max(head_radius, 0.001),
tail_mat, max(tail_radius, 0.001), segs)
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 original_mode == 'EDIT':
if arm_obj.mode == 'EDIT':
for bone in arm_obj.data.edit_bones:
meshify(bone.matrix, bone.length,
bone.head_radius, bone.tail_radius,
@ -198,9 +219,6 @@ class ARMATURE_OT_build_envelope_mesh(bpy.types.Operator):
bone.bone.head_radius, bone.bone.tail_radius,
bone.parent is None)
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()
@ -209,18 +227,6 @@ class ARMATURE_OT_build_envelope_mesh(bpy.types.Operator):
result_obj = bpy.data.objects.new(arm_obj.name + "_envelope", mesh)
context.collection.objects.link(result_obj)
bpy.ops.object.select_all(action='DESELECT')
result_obj.select_set(True)
context.view_layer.objects.active = result_obj
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'}
@ -237,8 +243,11 @@ class VIEW3D_PT_armature_mesher(bpy.types.Panel):
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 = layout.operator(
ARMATURE_OT_build_envelope_mesh.bl_idname,
text="Build Envelope Mesh",
icon='OUTLINER_OB_MESH',
)
op.segments = 16
@ -249,10 +258,12 @@ class VIEW3D_PT_armature_mesher(bpy.types.Panel):
classes = (ARMATURE_OT_build_envelope_mesh, VIEW3D_PT_armature_mesher)
def register():
for cls in classes: bpy.utils.register_class(cls)
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes): bpy.utils.unregister_class(cls)
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
if __name__ == "__main__":
register()