From 606df32fcbe8861f5f5768f38ae423f18e80c001 Mon Sep 17 00:00:00 2001 From: Seth Trowbridge Date: Sat, 28 Feb 2026 10:41:05 -0500 Subject: [PATCH] highly improved primitive building --- __init__.py | 193 +++++++++++++++++++++++++++------------------------- 1 file changed, 102 insertions(+), 91 deletions(-) diff --git a/__init__.py b/__init__.py index 7e091ff..1cb186d 100644 --- a/__init__.py +++ b/__init__.py @@ -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) @@ -172,22 +193,22 @@ class ARMATURE_OT_build_envelope_mesh(bpy.types.Operator): return obj is not None and obj.type == 'ARMATURE' 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 + arm_obj = context.active_object + world_mat = arm_obj.matrix_world + 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(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() \ No newline at end of file