diff --git a/__init__.py b/__init__.py index 629d95d..7e091ff 100644 --- a/__init__.py +++ b/__init__.py @@ -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: - """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. - """ +def _sphere_positions(radius: float, segments: int) -> list[Vector]: rings = max(segments // 2, 2) - verts = [] - - 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()) - + out = [] for r in range(rings + 1): phi = math.pi * r / rings - sin_phi = math.sin(phi) - cos_phi = math.cos(phi) + sp, cp = math.sin(phi), math.cos(phi) for s in range(segments): theta = 2.0 * math.pi * s / segments - local = Vector(( - radius * sin_phi * math.cos(theta), - radius * sin_phi * math.sin(theta), - radius * cos_phi, - )) - if rot is not None: - offset = (rot @ local.to_4d()).to_3d() - else: - offset = local - v = bm.verts.new(center + offset) - verts.append(v) + out.append(Vector((radius * sp * math.cos(theta), + radius * sp * math.sin(theta), + radius * cp))) + 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 _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 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] + sn = (s + 1) % segments 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: pass - return verts - def add_capped_frustum(bm: bmesh.types.BMesh, - head_center: Vector, head_radius: float, - tail_center: Vector, tail_radius: float, + head_mat: Matrix, head_radius: float, + tail_mat: Matrix, tail_radius: float, segments: int): """ - Add a capped tapered cylinder between two sphere centers. - Rings are placed at the sphere equators (offset = radius along axis) - so they sit flush with the sphere surfaces. - Each open end is closed with a triangle fan cap. + 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. """ - axis = tail_center - head_center - length = axis.length - if length < 1e-6: + 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 - ax = axis.normalized() - rot = _rotation_matrix_to_axis(ax) + 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))] - # 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): nxt = (i + 1) % segments try: - bm.faces.new((head_ring[i], head_ring[nxt], - tail_ring[nxt], tail_ring[i])) + bm.faces.new((hr[i], hr[nxt], tr[nxt], tr[i])) except ValueError: pass - # Head cap — fan, winding faces inward (away from tail) - head_cap_v = bm.verts.new(head_ring_center) + hcv = bm.verts.new(head_pos) for i in range(segments): - nxt = (i + 1) % segments 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: pass - # Tail cap — fan, winding faces outward (away from head) - tail_cap_v = bm.verts.new(tail_ring_center) + tcv = bm.verts.new(tail_pos) for i in range(segments): - nxt = (i + 1) % segments 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: 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 # --------------------------------------------------------------------------- @@ -143,7 +156,7 @@ class ARMATURE_OT_build_envelope_mesh(bpy.types.Operator): bl_label = "Build Envelope Mesh" bl_description = ( "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'} @@ -161,48 +174,30 @@ 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 - # Snapshot bone data NOW, while we're still in the original mode. - # This is especially important for EDIT mode where edit_bones are live. - # bones_data = collect_bone_data(arm_obj) // we arent doing this anymore - - 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 + 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_matrix_final, head_radius_final, segs, axis=axis_vec) - add_sphere(bm, tail_matrix_final, tail_radius_final, segs, axis=axis_vec) - add_capped_frustum(bm, - head_matrix_final, head_radius_final, - tail_matrix_final, tail_radius_final, - segs) + 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) - - # pose mode or object mode: - if mode == "EDIT": - bone_list = arm_obj.data.edit_bones - for bone in bone_list: - meshify(bone.head, bone.head_radius, bone.tail, bone.tail_radius, bone.parent is None) + if original_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: - bone_list = arm_obj.pose.bones - for bone in bone_list: - meshify(bone.head, bone.bone.head_radius, bone.tail, bone.bone.tail_radius, bone.parent is None) + 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) - - # Must be in OBJECT mode to create and link new mesh objects. if original_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) 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') result_obj.select_set(True) context.view_layer.objects.active = result_obj - # If we started in EDIT mode, restore it on the armature now. 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) - + 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'} @@ -247,18 +235,10 @@ class VIEW3D_PT_armature_mesher(bpy.types.Panel): bl_region_type = 'UI' 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): 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 @@ -266,18 +246,13 @@ class VIEW3D_PT_armature_mesher(bpy.types.Panel): # Registration # --------------------------------------------------------------------------- -classes = ( - ARMATURE_OT_build_envelope_mesh, - VIEW3D_PT_armature_mesher, -) +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() \ No newline at end of file