"""Procedurally generates the Cosmic Clash ship hull model. Run inside Blender (e.g. via the blender-mcp `execute_blender_code` tool, or Blender's own script editor / `blender --background --python gen_ship.py`). Builds six named mesh objects — Hull, Nose, Canopy, TailFin, EngineGlowL, EngineGlowR — greebled from bmesh primitives (bevel + inset/extrude), saves ship.blend, then exports each part as its OWN glb file (ship_hull.glb, ship_nose.glb, etc.) rather than one combined file. Why per-part files: Godot cannot reliably resolve a single named sub-mesh out of a multi-object glTF via `path.glb::ArrayMesh_xxx` addressing from a *different* .tscn — that syntax only resolves once the whole glTF scene has already been instanced elsewhere in the same session; a fresh load of just that ext_resource reference silently returns a null mesh. Exporting one object per glb file avoids that (each file's root is a clean, individually loadable node), but each still imports as a synthetic Node3D wrapper with ONE MeshInstance3D child — not the MeshInstance3D itself. `ship.tscn` needs the mesh nodes as *direct* children of the Ship root (see node-name note below), so after running this script, run `extract_meshes.gd` (in this same directory) inside Godot to pull each part's ArrayMesh out into its own `.res` file — that's what `ship.tscn` actually references. Node names matter: `scripts/ship.gd`'s `_apply_team_color()` recolors direct children of the Ship node named exactly "Nose" and "TailFin" at runtime, so those two object names must be preserved. Orientation: empirically, Blender's -Y axis (this script builds the nose at -Y) maps to Godot's **+Z** on glTF export (not -Z as the naive Blender-Z-up vs. Godot-Y-up mapping might suggest) — verified in-engine, not assumed. Since ship forward is Godot -Z (see `ship.gd`), `ship.tscn` applies a 180-degree rotation (`Transform3D(-1,0,0, 0,1,0, 0,0,-1, ...)`) to every part's instance node to correct this. If you regenerate the parts, that correction stays in `ship.tscn` — don't rebuild it into the Blender geometry too, or the two fixes will cancel out and the ship will face backwards again. Silhouette budget: the ship's RigidBody3D collision shape (`ship.tscn`, BoxShape3D) is (1, 1, 4) in Godot space — X width, Y height, Z length. In this script's local (pre-export) space X=width, Y=length (nose at -Y), Z=height, and the budget is X +-0.5, Y(length) +-2.0, Z(height) +-0.5. The built silhouette is kept reasonably close to that budget, not an exact fit (matching the original primitive ship, which was also slightly over at a few extremities). """ import math import random import bmesh import bpy from mathutils import Matrix, Vector BLEND_PATH = "Game/assets/blender_models/ship.blend" PART_GLB_PATHS = { "Hull": "Game/assets/models/ship_hull.glb", "Nose": "Game/assets/models/ship_nose.glb", "Canopy": "Game/assets/models/ship_canopy.glb", "TailFin": "Game/assets/models/ship_tailfin.glb", "EngineGlowL": "Game/assets/models/ship_engine_l.glb", "EngineGlowR": "Game/assets/models/ship_engine_r.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 remove_object(name): obj = bpy.data.objects.get(name) if obj: mesh = obj.data bpy.data.objects.remove(obj, do_unlink=True) if mesh.users == 0: bpy.data.meshes.remove(mesh) 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_hull(): random.seed(7) bm = bmesh.new() bmesh.ops.create_cube(bm, size=1.0) # Full dims: width 0.9, length 2.6, height 0.6 (collision budget is 1 x 4 x 1 full) bmesh.ops.scale(bm, vec=(0.9, 2.6, 0.6), verts=bm.verts) bm.edges.ensure_lookup_table() long_edges = [e for e in bm.edges if (e.verts[0].co - e.verts[1].co).length > 2.0] bmesh.ops.bevel(bm, geom=long_edges, offset=0.1, segments=3, affect="EDGES") bm.faces.ensure_lookup_table() panel_target_faces = [f for f in bm.faces if abs(f.normal.z) > 0.6 or abs(f.normal.x) > 0.6] edges_to_cut = list({e for f in panel_target_faces for e in f.edges}) bmesh.ops.subdivide_edges(bm, edges=edges_to_cut, cuts=3, use_grid_fill=True) def greeble_pass(seed, fraction, thickness, depth_range): random.seed(seed) bm.faces.ensure_lookup_table() candidates = [ f for f in bm.faces if (abs(f.normal.z) > 0.55 or abs(f.normal.x) > 0.55) and 0.01 < f.calc_area() < 0.35 ] random.shuffle(candidates) chosen = candidates[: max(1, int(len(candidates) * fraction))] for f in chosen: if not f.is_valid: continue res = bmesh.ops.inset_individual(bm, faces=[f], thickness=thickness) new_faces = res["faces"] if new_faces: nf = new_faces[0] depth = random.uniform(*depth_range) bmesh.ops.translate(bm, verts=nf.verts, vec=nf.normal * depth) greeble_pass(seed=7, fraction=1 / 3, thickness=0.02, depth_range=(-0.03, 0.022)) greeble_pass(seed=42, fraction=0.25, thickness=0.018, depth_range=(-0.028, 0.018)) bmesh.ops.recalc_face_normals(bm, faces=bm.faces) return new_mesh_object("Hull", bm) def build_nose(): random.seed(11) bm = bmesh.new() bmesh.ops.create_cube(bm, size=1.0) bmesh.ops.scale(bm, vec=(0.82, 0.25, 0.5), verts=bm.verts) bmesh.ops.translate(bm, vec=(0.0, -1.425, 0.0), verts=bm.verts) def front_face(): bm.faces.ensure_lookup_table() return min(bm.faces, key=lambda f: f.calc_center_median().y) # Stage 1 taper: extrude forward, narrow to ~half width/height. f = front_face() r = bmesh.ops.extrude_face_region(bm, geom=[f]) new_verts = [v for v in r["geom"] if isinstance(v, bmesh.types.BMVert)] bmesh.ops.translate(bm, verts=new_verts, vec=(0, -0.32, 0.02)) pivot = sum((v.co for v in new_verts), Vector()) / len(new_verts) bmesh.ops.scale(bm, verts=new_verts, vec=(0.5, 1.0, 0.55), space=Matrix.Translation(-pivot)) # Stage 2 taper: extrude further to a near-point tip. bm.faces.ensure_lookup_table() f2 = front_face() r2 = bmesh.ops.extrude_face_region(bm, geom=[f2]) new_verts2 = [v for v in r2["geom"] if isinstance(v, bmesh.types.BMVert)] bmesh.ops.translate(bm, verts=new_verts2, vec=(0, -0.28, 0.0)) pivot2 = sum((v.co for v in new_verts2), Vector()) / len(new_verts2) bmesh.ops.scale(bm, verts=new_verts2, vec=(0.08, 1.0, 0.08), space=Matrix.Translation(-pivot2)) bm.edges.ensure_lookup_table() sharp_edges = [e for e in bm.edges if e.calc_face_angle(1.0) > 0.35] bmesh.ops.bevel(bm, geom=sharp_edges, offset=0.02, segments=2, affect="EDGES") bm.faces.ensure_lookup_table() side_faces = [f for f in bm.faces if abs(f.normal.x) > 0.5 and f.calc_area() > 0.01][:2] for f in side_faces: if not f.is_valid: continue res = bmesh.ops.inset_individual(bm, faces=[f], thickness=0.02) nf = res["faces"][0] if res["faces"] else None if nf: bmesh.ops.translate(bm, verts=nf.verts, vec=nf.normal * -0.015) bmesh.ops.recalc_face_normals(bm, faces=bm.faces) return new_mesh_object("Nose", bm) def build_canopy(): bm = bmesh.new() bmesh.ops.create_icosphere(bm, subdivisions=2, radius=0.3) bm.faces.ensure_lookup_table() faces_to_del = [f for f in bm.faces if f.calc_center_median().z < -0.02] bmesh.ops.delete(bm, geom=faces_to_del, context="FACES") bm.edges.ensure_lookup_table() boundary_edges = [e for e in bm.edges if len(e.link_faces) == 1] if boundary_edges: bmesh.ops.holes_fill(bm, edges=boundary_edges) bmesh.ops.recalc_face_normals(bm, faces=bm.faces) # Swept teardrop canopy: wider, stretched fore-aft, low profile, tapered at the rear. for v in bm.verts: v.co.x *= 1.5 v.co.y *= 2.2 v.co.z *= 0.82 if v.co.y > 0: t = min(v.co.y / 0.62, 1.0) v.co.z *= 1.0 - 0.35 * t v.co.x *= 1.0 - 0.25 * t obj = new_mesh_object("Canopy", bm) # Front-upper on the hull; kept low so the top stays close to the +-0.5 height budget. obj.location = (0.0, -0.55, 0.31) return obj def build_nacelle(name, x_offset): bm = bmesh.new() bmesh.ops.create_cone( bm, cap_ends=True, cap_tris=False, segments=12, radius1=0.11, radius2=0.095, depth=0.85 ) # Cone is built along Z by default; rotate -90 deg about X to align its axis with Y (length). rot = Matrix.Rotation(-math.pi / 2, 4, "X") bmesh.ops.transform(bm, matrix=rot, verts=bm.verts) bm.edges.ensure_lookup_table() ring_edges = [ e for e in bm.edges if len(e.link_faces) == 2 and abs(e.verts[0].co.y - e.verts[1].co.y) < 0.01 ] if ring_edges: bmesh.ops.bevel(bm, geom=ring_edges, offset=0.012, segments=2, affect="EDGES") obj = new_mesh_object(name, bm) # Rear of the ship, flanking the hull, slightly below centerline. obj.location = (x_offset, 0.95, -0.05) return obj def build_tailfin(): bm = bmesh.new() bmesh.ops.create_cube(bm, size=1.0) bmesh.ops.scale(bm, vec=(0.06, 0.65, 0.42), verts=bm.verts) # Sweep the top-rear edge back, taper the top edge thin. for v in bm.verts: if v.co.z > 0: v.co.y += 0.16 if v.co.y > 0 else 0.04 v.co.x *= 0.35 bm.edges.ensure_lookup_table() sharp_edges = [e for e in bm.edges if e.calc_face_angle(1.0) > 0.3] bmesh.ops.bevel(bm, geom=sharp_edges, offset=0.012, segments=2, affect="EDGES") bmesh.ops.recalc_face_normals(bm, faces=bm.faces) obj = new_mesh_object("TailFin", bm) obj.location = (0.0, 0.72, 0.24) return obj 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(): mat_hull = make_material("Mat_Hull", (0.35, 0.37, 0.42), 0.6, 0.4) # Accent starts team-blue; Ship._apply_team_color() overrides this per-team at runtime. mat_accent = make_material("Mat_Accent", (0.25, 0.55, 1.0), 0.3, 0.5, (0.25, 0.55, 1.0), 0.9) mat_canopy = make_material("Mat_Canopy", (0.15, 0.85, 1.0), 0.8, 0.1, (0.15, 0.85, 1.0), 1.4) mat_nacelle_body = make_material("Mat_NacelleBody", (0.22, 0.24, 0.28), 0.7, 0.35) mat_engine_glow = make_material( "Mat_EngineGlow", (1.0, 0.55, 0.15), 0.1, 0.4, (1.0, 0.55, 0.15), 4.0 ) def assign_single(obj_name, mat): obj = bpy.data.objects[obj_name] obj.data.materials.clear() obj.data.materials.append(mat) assign_single("Hull", mat_hull) assign_single("Nose", mat_accent) assign_single("TailFin", mat_accent) assign_single("Canopy", mat_canopy) for name in ["EngineGlowL", "EngineGlowR"]: obj = bpy.data.objects[name] obj.data.materials.clear() obj.data.materials.append(mat_nacelle_body) # slot 0: body obj.data.materials.append(mat_engine_glow) # slot 1: rear exhaust glow cap polys = obj.data.polygons max_y = max(p.center.y for p in polys) for p in polys: p.material_index = 1 if p.center.y > max_y - 0.01 else 0 def build_ship(): clear_scene() build_hull() build_nose() build_canopy() build_nacelle("EngineGlowL", -0.42) build_nacelle("EngineGlowR", 0.42) build_tailfin() assign_materials() def export(repo_root): import os blend_path = os.path.join(repo_root, BLEND_PATH) bpy.ops.wm.save_as_mainfile(filepath=blend_path) for obj_name, rel_path in PART_GLB_PATHS.items(): bpy.ops.object.select_all(action="DESELECT") obj = bpy.data.objects[obj_name] obj.select_set(True) bpy.context.view_layer.objects.active = obj bpy.ops.export_scene.gltf( filepath=os.path.join(repo_root, rel_path), export_format="GLB", use_selection=True, export_apply=True, export_materials="EXPORT", ) print("Exported per-part glbs. Now run extract_meshes.gd inside Godot") print("to produce the ship_*.res files that ship.tscn references.") if __name__ == "__main__": import os # Assumes this script lives at /tools/blender/gen_ship.py. repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) build_ship() export(repo_root)