"""Procedurally generates the Cosmic Clash match ball model. Run inside Blender (e.g. via the blender-mcp `execute_blender_code` tool, or `blender --background --python gen_ball.py`). Builds a single "Ball" mesh object: a smooth-shaded, undeformed high-resolution icosphere (every vertex stays exactly on the radius-1.0 sphere, so the silhouette is perfectly round and matches the untouched SphereShape3D collision) with a metallic gold body and a pair of crossed emissive "energy seam" rings applied purely via per-face material assignment — no geometry is pushed off the sphere, unlike an earlier beveled/faceted revision that read as an angular gem rather than a round ball. Built at native radius 1.0 to match `ball.tscn`'s existing MeshInstance3D scale of 0.5 (world radius 0.5). Keep this convention if regenerating, so `ball.tscn` doesn't need its scale transform changed. After running this script, run `extract_meshes.gd` (in this same directory) inside Godot to produce `ball.res`, which is what `ball.tscn` actually references — not `ball.glb` directly. A glTF import's PackedScene wraps its mesh object in a synthetic Node3D root, and referencing a named sub-mesh of that scene via `path.glb::ArrayMesh_xxx` from a *different* .tscn doesn't reliably resolve (it silently returns a null mesh unless that exact glTF scene has already been instanced elsewhere first) — extracting a standalone `.res` avoids that entirely. """ import bpy import bmesh BLEND_PATH = "Game/assets/blender_models/ball.blend" GLB_PATH = "Game/assets/models/ball.glb" def new_mesh_object(name, bm): mesh = bpy.data.meshes.new(name) bm.to_mesh(mesh) bm.free() obj = bpy.data.objects.new(name, mesh) bpy.context.collection.objects.link(obj) return obj def clear_scene(): for obj in list(bpy.data.objects): if obj.type in {"MESH", "EMPTY"}: bpy.data.objects.remove(obj, do_unlink=True) for block in list(bpy.data.meshes): if block.users == 0: bpy.data.meshes.remove(block) for block in list(bpy.data.materials): if block.users == 0: bpy.data.materials.remove(block) def build_ball_mesh(): bm = bmesh.new() # No bevel/inset — geometry stays an undeformed sphere so the silhouette # is perfectly round; all surface detail below is material-only. bmesh.ops.create_icosphere(bm, subdivisions=4, radius=1.0) bmesh.ops.recalc_face_normals(bm, faces=bm.faces) ball = new_mesh_object("Ball", bm) for p in ball.data.polygons: p.use_smooth = True return ball def make_material(name, base_rgb, metallic, roughness, emission_rgb=None, emission_strength=0.0): existing = bpy.data.materials.get(name) if existing: bpy.data.materials.remove(existing) mat = bpy.data.materials.new(name) mat.use_nodes = True bsdf = mat.node_tree.nodes.get("Principled BSDF") bsdf.inputs["Base Color"].default_value = (*base_rgb, 1.0) bsdf.inputs["Metallic"].default_value = metallic bsdf.inputs["Roughness"].default_value = roughness if emission_rgb is not None: bsdf.inputs["Emission Color"].default_value = (*emission_rgb, 1.0) bsdf.inputs["Emission Strength"].default_value = emission_strength return mat def assign_materials(ball): mat_body = make_material("Mat_GoldBody", (0.85, 0.65, 0.15), 0.92, 0.18) mat_accent = make_material( "Mat_GoldAccent", (1.0, 0.85, 0.4), 0.7, 0.2, (1.0, 0.8, 0.35), 3.0 ) ball.data.materials.clear() ball.data.materials.append(mat_body) # 0 ball.data.materials.append(mat_accent) # 1 # Two crossed great-circle "energy seam" rings, selected purely by face # center position — a deliberate accent pattern rather than random speckle, # with zero effect on vertex positions. for p in ball.data.polygons: cx, _, cz = p.center on_equator_ring = abs(cz) < 0.09 on_meridian_ring = abs(cx) < 0.09 p.material_index = 1 if (on_equator_ring or on_meridian_ring) else 0 def build_ball(): clear_scene() ball = build_ball_mesh() assign_materials(ball) def export(repo_root): import os blend_path = os.path.join(repo_root, BLEND_PATH) glb_path = os.path.join(repo_root, GLB_PATH) bpy.ops.wm.save_as_mainfile(filepath=blend_path) bpy.ops.object.select_all(action="SELECT") bpy.ops.export_scene.gltf( filepath=glb_path, export_format="GLB", use_selection=True, export_apply=True, export_materials="EXPORT", ) if __name__ == "__main__": import os repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) build_ball() export(repo_root)