"""Procedurally generates the Cosmic Clash nebula-arena decoration models. Run inside Blender (e.g. via the blender-mcp `execute_blender_code` tool, or `blender --background --python gen_nebula.py`). Builds two named mesh objects — NebulaStation and NebulaDebris — greebled from bmesh primitives (bevel + inset/extrude, same technique as `gen_ship.py`'s hull), saves nebula_decoration.blend, then exports each as its own glb file (nebula_station.glb, nebula_debris.glb). Unlike ship/ball, these are decoration props instanced directly as glTF scenes in `arena_02.tscn` (see `Decoration` node) with no per-part node-name or forward-orientation requirement, so — also unlike ship/ball — there is no `extract_meshes.gd` step needed afterward and no axis-correction rotation to keep in sync. Each object is still exported as a single-object glb (rather than combining both into one file) so the arena scene's existing `ext_resource` references keep resolving to the same top-level structure they already expect. NebulaPlanet lives in the same source .blend but is a separate, unrelated asset (`Planet_Surface` material) — this script deliberately never touches it; `clear_scene()` only removes the two objects it rebuilds. Bake pipeline: a full low-poly/high-poly normal+AO bake was considered (per the TODO item this script implements) but skipped as too fragile to script reliably end-to-end without a human eyeballing the bake result — detail here comes entirely from real geometry (inset/extrude greeble) plus per-face material variation instead, which is the TODO's explicitly sanctioned fallback. """ import random import bmesh import bpy from mathutils import Vector BLEND_PATH = "Game/assets/blender_models/nebula_decoration.blend" GLB_PATHS = { "NebulaStation": "Game/assets/models/nebula_station.glb", "NebulaDebris": "Game/assets/models/nebula_debris.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(): # Scoped to just the two objects this script rebuilds — NebulaPlanet # (and its Planet_Surface material) must survive untouched. for name in ("NebulaStation", "NebulaDebris"): obj = bpy.data.objects.get(name) if obj: mesh = obj.data bpy.data.objects.remove(obj, do_unlink=True) if mesh and mesh.users == 0: bpy.data.meshes.remove(mesh) for block in list(bpy.data.materials): if block.users == 0: bpy.data.materials.remove(block) def build_station(): random.seed(21) bm = bmesh.new() bmesh.ops.create_cube(bm, size=1.0) # Matches the original kitbash's proportions: a tall central hub, long # axis along local Y (half-extents 0.5, 2.0, 0.5). bmesh.ops.scale(bm, vec=(1.0, 4.0, 1.0), 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.12, segments=3, affect="EDGES") # Panel-line seams: subdivide the side/top/bottom faces (exclude the Y # end-caps), same face-selection logic as gen_ship.py's hull. 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=4, 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.015 < f.calc_area() < 0.6 ] 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=21, fraction=0.35, thickness=0.045, depth_range=(-0.07, 0.05)) greeble_pass(seed=55, fraction=0.22, thickness=0.032, depth_range=(-0.05, 0.035)) greeble_pass(seed=88, fraction=0.15, thickness=0.02, depth_range=(-0.03, 0.02)) bmesh.ops.recalc_face_normals(bm, faces=bm.faces) obj = new_mesh_object("NebulaStation", bm) obj.location = (0.0, 0.0, 1.2871222496032715) return obj def build_debris(): random.seed(303) bm = bmesh.new() bmesh.ops.create_cube(bm, size=1.0) bmesh.ops.subdivide_edges(bm, edges=list(bm.edges), cuts=2, use_grid_fill=True) # Irregular rock silhouette: per-vertex jitter breaks the clean box # shape, unlike the station's crisp architectural hull. bm.verts.ensure_lookup_table() for v in bm.verts: v.co += Vector( ( random.uniform(-0.18, 0.18), random.uniform(-0.18, 0.18), random.uniform(-0.18, 0.18), ) ) bmesh.ops.scale(bm, vec=(0.9, 0.7, 0.55), verts=bm.verts) bm.edges.ensure_lookup_table() jagged_edges = [e for e in bm.edges if (e.verts[0].co - e.verts[1].co).length > 0.25] bmesh.ops.bevel(bm, geom=jagged_edges, offset=0.03, segments=1, affect="EDGES") # Damage gouges/impact craters: inset a subset of faces and push them in. bm.faces.ensure_lookup_table() candidates = [f for f in bm.faces if 0.01 < f.calc_area() < 0.3] random.shuffle(candidates) for f in candidates[: max(1, len(candidates) // 4)]: if not f.is_valid: continue res = bmesh.ops.inset_individual(bm, faces=[f], thickness=0.03) new_faces = res["faces"] if new_faces: nf = new_faces[0] depth = random.uniform(-0.09, -0.02) bmesh.ops.translate(bm, verts=nf.verts, vec=nf.normal * depth) bmesh.ops.recalc_face_normals(bm, faces=bm.faces) obj = new_mesh_object("NebulaDebris", bm) obj.location = (-0.0355125367641449, -0.0051781535148620605, 0.027770817279815674) 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_station_materials(): # Same four material names as the original kitbash (slot order # preserved) so anything keying off material name downstream (glow # tuning, etc.) keeps working unchanged. mat_metal = make_material("Hull_Metal", (0.55, 0.57, 0.60), 0.8, 0.45) mat_dark = make_material("Hull_Metal_Dark", (0.16, 0.17, 0.19), 0.6, 0.6) mat_light = make_material("Hull_Metal_Light", (0.72, 0.73, 0.75), 0.85, 0.35) mat_accent = make_material( "Hull_Accent", (0.02, 0.02, 0.02), 0.1, 0.3, (0.3, 0.75, 0.95), 3.0 ) obj = bpy.data.objects["NebulaStation"] obj.data.materials.clear() for m in (mat_metal, mat_dark, mat_light, mat_accent): obj.data.materials.append(m) # Three separate emissive window/light strips at different points along # the hub's length, replacing the single strip of the original kitbash. strip_bands = (-1.4, 0.0, 1.4) strip_half_width = 0.15 random.seed(103) for p in obj.data.polygons: cx, cy, cz = p.center nx, ny, nz = p.normal is_side_facing = abs(nx) > 0.5 or abs(nz) > 0.5 is_strip = is_side_facing and any(abs(cy - band) < strip_half_width for band in strip_bands) if is_strip: p.material_index = 3 continue r = random.random() if r < 0.06: p.material_index = 0 elif r < 0.35: p.material_index = 1 else: p.material_index = 2 def assign_debris_materials(): # Deliberately distinct from the station's clean Hull_Metal_Dark — # darker, rougher rock body plus blotchy scorch-mark patches, so debris # reads as separate wreckage rather than a station material clone. mat_rock = make_material("Mat_DebrisRock", (0.13, 0.11, 0.10), 0.05, 0.85) mat_scorch = make_material("Mat_DebrisScorch", (0.03, 0.03, 0.03), 0.0, 0.95) obj = bpy.data.objects["NebulaDebris"] obj.data.materials.clear() obj.data.materials.append(mat_rock) obj.data.materials.append(mat_scorch) random.seed(404) scorch_centers = [ Vector( ( random.uniform(-0.45, 0.45), random.uniform(-0.35, 0.35), random.uniform(-0.28, 0.28), ) ) for _ in range(4) ] scorch_radius = 0.22 for p in obj.data.polygons: c = Vector(p.center) near_scorch = any((c - sc).length < scorch_radius for sc in scorch_centers) p.material_index = 1 if near_scorch else 0 def build_nebula(): clear_scene() build_station() assign_station_materials() build_debris() assign_debris_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 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", ) if __name__ == "__main__": import os # Assumes this script lives at /tools/blender/gen_nebula.py. repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) build_nebula() export(repo_root)