""" Exports the Kazah Tender from the user's .blend into FBX files + a manifest for Unreal 5.8. Run headless (Tools/export_kazah.ps1 does this): blender.exe -b D:\\HADAL - The Descent\\models\\Kazah-stern-cavity-fixedv4.blend --python-exit-code 1 ^ --python Tools\\blender\\export_kazah.py -- [--out DIR] [--subsurf-level N] THE SOURCE .BLEND IS NEVER SAVED. Everything below works on copies made in memory (evaluated meshes with modifiers applied, new objects in a scratch collection); the script compares the file's size and mtime before and after and aborts if they differ. What it produces in SourceArt/Kazah/: Kazah_.fbx one file per part. Each part's vertices are stored relative to ITS PIVOT, so the pivot is the asset origin in Unreal and the part can be rotated about it. kazah_manifest.json pivots, bounds, materials (with PBR values), triangle counts. The Unreal import script reads it; nothing in it is guessed at import time. and writes Source/TheDescent/Vehicles/KazahLayout.inl, the table of pivots the C++ actor assembles the parts from (cm, Unreal axes). Coordinate systems Blender: right-handed, +Z up, the Tender's nose points to -Y, so port (left) is +X and starboard is -X. Unreal: left-handed, +Z up, +X forward, +Y right. => ue = (-blender_y, -blender_x, blender_z) * 100 cm The mapping has determinant -1 (a reflection, as any right->left-handed change must). We let the FBX exporter/importer pair do the conversion (Blender: forward -Y, up Z; Unreal: "force front X axis") and independently predict the result with the formula above. The Unreal import script compares the imported bounds with the prediction in the manifest and fails loudly if the pair disagrees. """ import bpy import bmesh import json import os import re import sys import time from mathutils import Matrix, Vector # --------------------------------------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------------------------------------- THIS = os.path.abspath(__file__) PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(THIS), "..", "..")) DEFAULT_OUT = os.path.join(PROJECT_ROOT, "SourceArt", "Kazah") LAYOUT_INL = os.path.join(PROJECT_ROOT, "Source", "TheDescent", "Vehicles", "KazahLayout.inl") # Parts that stay separate. Everything else in the file is static and is merged into "Body". # pivot: where the part's axis of motion is, in BLENDER world metres. 'origin' uses the object's own origin # (the user placed the rotor origins on the hubs); 'bbox_center' and the explicit tuples are our choices. # # GUESSES (the .blend has no hinge data; change them here and re-run if the design wants otherwise): # Canopy_Lid / Canopy_Glass hinge along the lid's rear-bottom edge, axis = lateral (X). Both share it so the # glass can be parented to the lid and move with it. # Hatch_Lid hinge along the aft edge of the roof hatch, axis = lateral (X). # Tool_Housing pivot at its bounding-box centre. # Nose_Lens, Chin_Sensor pivot at their centres. SEPARATE_PARTS = [ # name, source object(s), pivot rule, role ("Cockpit_Interior", ["Cockpit_Interior"], "zero", "static"), ("Rotor_Port", ["Rotor_Port"], "origin", "rotor"), ("Rotor_Stbd", ["Rotor_Stbd"], "origin", "rotor"), ("Canopy_Glass", ["Canopy_Glass"], "lid_hinge", "glass"), ("Canopy_Lid", ["Canopy_Lid"], "lid_hinge", "hinged"), ("Hatch_Lid", ["Hatch_Lid"], "hatch_hinge", "hinged"), ("Tool_Housing", ["Tool_Housing"], "bbox_center", "tool"), ("Nose_Lens", ["Nose_Lens"], "bbox_center", "lens"), ("Chin_Sensor", ["Chin_Sensor"], "bbox_center", "sensor"), ] BODY_NAME = "Body" NANITE_PARTS = {"Body"} # ~900k triangles after the hull's subdivision; everything else is small FBX_FORWARD = "-Y" FBX_UP = "Z" def log(*a): print("[export_kazah]", *a, flush=True) def die(msg): print("[export_kazah] ERROR:", msg, file=sys.stderr, flush=True) sys.exit(1) def parse_args(): argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] out, subsurf = DEFAULT_OUT, None i = 0 while i < len(argv): if argv[i] == "--out": out = os.path.abspath(argv[i + 1]); i += 2 elif argv[i] == "--subsurf-level": subsurf = int(argv[i + 1]); i += 2 else: die("unknown argument " + argv[i]) return out, subsurf # --------------------------------------------------------------------------------------------------------- # Materials # --------------------------------------------------------------------------------------------------------- def canonical_material_name(name): """'KAZAH | Satin titanium.003' -> 'KZ_Satin_titanium' (the file has many numbered duplicates).""" n = re.sub(r"\.\d{3}$", "", name) n = n.replace("KAZAH |", "").strip() n = re.sub(r"[^A-Za-z0-9]+", "_", n).strip("_") return "KZ_" + n def read_principled(mat): """Pulls plain PBR numbers out of a Principled BSDF. Texture-less in this file; a roughness driven by a noise->map-range chain (micro variation) is replaced by the middle of the map-range's output span.""" out = {"base_color": [0.5, 0.5, 0.5], "metallic": 0.0, "roughness": 0.5, "alpha": 1.0, "transmission": 0.0, "ior": 1.5, "emissive_color": [0.0, 0.0, 0.0], "emissive_strength": 0.0, "roughness_driven_by_noise": False} if not mat or not mat.use_nodes: return out bsdf = next((n for n in mat.node_tree.nodes if n.type == "BSDF_PRINCIPLED"), None) if not bsdf: return out def val(name): s = bsdf.inputs.get(name) return s.default_value if s is not None else None bc = val("Base Color") out["base_color"] = [round(bc[0], 4), round(bc[1], 4), round(bc[2], 4)] out["metallic"] = round(float(val("Metallic")), 4) out["alpha"] = round(float(val("Alpha")), 4) out["transmission"] = round(float(val("Transmission Weight")), 4) out["ior"] = round(float(val("IOR")), 4) out["roughness"] = round(float(val("Roughness")), 4) ec = val("Emission Color") out["emissive_color"] = [round(ec[0], 4), round(ec[1], 4), round(ec[2], 4)] out["emissive_strength"] = round(float(val("Emission Strength")), 4) rough_in = bsdf.inputs.get("Roughness") if rough_in is not None and rough_in.is_linked: src = rough_in.links[0].from_node if src.type == "MAP_RANGE": lo = src.inputs["To Min"].default_value hi = src.inputs["To Max"].default_value lo = lo if isinstance(lo, float) else lo[0] hi = hi if isinstance(hi, float) else hi[0] # Map Range outputs "To Min..To Max" as its input goes 0..1; the value that reaches the BSDF # is therefore between them. Without the noise texture its mean is the best constant. out["roughness"] = round(float((lo + hi) * 0.5), 4) # The original authored base roughness (the unlinked default) is what the artist dialled in; the # noise is subtle variation around it, so prefer it when it falls inside the span. base = float(bsdf.inputs["Roughness"].default_value) if min(lo, hi) <= base <= max(lo, hi): out["roughness"] = round(base, 4) out["roughness_driven_by_noise"] = True return out def classify_material(name, pbr): if pbr["transmission"] >= 0.9 and pbr["emissive_strength"] == 0.0: return "glass" if pbr["emissive_strength"] >= 1.0: return "lens" # emissive, opaque in Unreal (the headlight crystal) if pbr["emissive_strength"] > 0.0: return "emissive" # faint glow, e.g. 'Optical cyan' instrument lenses return "opaque" # --------------------------------------------------------------------------------------------------------- # Geometry helpers # --------------------------------------------------------------------------------------------------------- def blender_to_ue_cm(v): # "+ 0.0" turns -0.0 into 0.0 so the generated tables do not print "-0.000". return [round(-v.y * 100.0, 3) + 0.0, round(-v.x * 100.0, 3) + 0.0, round(v.z * 100.0, 3) + 0.0] def world_bbox(obj): pts = [obj.matrix_world @ Vector(c) for c in obj.bound_box] mn = Vector((min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts))) mx = Vector((max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts))) return mn, mx def bbox_ue_cm(mn, mx): """Blender-space box -> Unreal-space box in cm (the mapping swaps and negates axes, so re-sort).""" a, b = blender_to_ue_cm(mn), blender_to_ue_cm(mx) return ([min(a[i], b[i]) for i in range(3)], [max(a[i], b[i]) for i in range(3)]) def outward_ratio(obj): """Fraction of faces whose normal points away from the mesh centroid. Diagnostic only: about 0.5+ for any sane closed shape; a value near 0 would mean the winding was flipped by a negative scale.""" me = obj.data if not me.polygons: return 1.0 c = sum((p.center for p in me.polygons), Vector()) / len(me.polygons) good = sum(1 for p in me.polygons if (p.center - c).dot(p.normal) > 0) return good / len(me.polygons) def tri_count(obj): me = obj.data me.calc_loop_triangles() return len(me.loop_triangles) def evaluated_copy(obj, depsgraph, collection, name): """A new mesh object holding obj's mesh with every modifier applied, at obj's world transform.""" ev = obj.evaluated_get(depsgraph) me = bpy.data.meshes.new_from_object(ev, preserve_all_data_layers=True, depsgraph=depsgraph) me.name = name copy = bpy.data.objects.new(name, me) copy.matrix_world = obj.matrix_world.copy() collection.objects.link(copy) return copy def op_override(active, selected): return bpy.context.temp_override( active_object=active, object=active, selected_objects=selected, selected_editable_objects=selected) def apply_transforms(obj): """Bakes location/rotation/scale into the vertices. The operator also fixes winding for negative scales.""" with op_override(obj, [obj]): bpy.ops.object.transform_apply(location=True, rotation=True, scale=True, properties=False) def remap_materials(obj, mat_cache, source_materials): """Replaces every slot by the canonical material, merging duplicate slots, and fixes face indices.""" me = obj.data old = [s.material for s in obj.material_slots] new_names = [] old_to_new = {} for i, m in enumerate(old): cn = canonical_material_name(m.name) if m else "KZ_Default" if cn not in new_names: new_names.append(cn) old_to_new[i] = new_names.index(cn) if cn not in mat_cache: mat_cache[cn] = {"source": m, "pbr": read_principled(m)} idx = [0] * len(me.polygons) me.polygons.foreach_get("material_index", idx) idx = [old_to_new.get(i, 0) for i in idx] me.materials.clear() for cn in new_names: bm = source_materials.get(cn) if bm is None: bm = bpy.data.materials.new(cn) src = mat_cache[cn]["source"] pbr = mat_cache[cn]["pbr"] bm.diffuse_color = (*pbr["base_color"], 1.0) # carried in the FBX for tools that look; Unreal ignores it source_materials[cn] = bm me.materials.append(bm) me.polygons.foreach_set("material_index", idx) # after the slots exist, see drop_unused_slots me.update() def drop_unused_slots(obj): """Removes material slots no face uses and renumbers the faces (done by hand: the operator needs UI context).""" me = obj.data idx = [0] * len(me.polygons) me.polygons.foreach_get("material_index", idx) used = sorted(set(idx)) if len(used) == len(me.materials): return keep = [me.materials[i] for i in used] remap = {old: new for new, old in enumerate(used)} # Rebuild the slot list FIRST and write the face indices afterwards: clearing the slots resets every # face's index to 0, so indices written before the clear are lost. me.materials.clear() for m in keep: me.materials.append(m) me.polygons.foreach_set("material_index", [remap[i] for i in idx]) me.update() def join_objects(objs, name, collection): if len(objs) == 1: objs[0].name = name objs[0].data.name = name return objs[0] active = objs[0] with op_override(active, objs): bpy.ops.object.join() active.name = name active.data.name = name return active def translate_mesh(obj, offset): obj.data.transform(Matrix.Translation(offset)) obj.data.update() def export_fbx(obj, path): for o in bpy.context.scene.objects: o.select_set(False) obj.select_set(True) bpy.context.view_layer.objects.active = obj bpy.ops.export_scene.fbx( filepath=path, use_selection=True, object_types={"MESH"}, use_mesh_modifiers=False, # already applied mesh_smooth_type="OFF", # write the normals as they are add_leaf_bones=False, bake_anim=False, axis_forward=FBX_FORWARD, axis_up=FBX_UP, global_scale=1.0, apply_unit_scale=True, apply_scale_options="FBX_SCALE_NONE", use_space_transform=True, bake_space_transform=False, path_mode="STRIP", use_metadata=False, ) # --------------------------------------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------------------------------------- def main(): out_dir, subsurf_override = parse_args() src = bpy.data.filepath if not src: die("run this with the .blend opened: blender -b --python export_kazah.py") st0 = os.stat(src) t_start = time.time() os.makedirs(out_dir, exist_ok=True) scene = bpy.context.scene log("source:", src) log("unit system:", scene.unit_settings.system, "scale_length:", scene.unit_settings.scale_length) if scene.unit_settings.system != "METRIC" or abs(scene.unit_settings.scale_length - 1.0) > 1e-9: die("expected metric units with scale 1.0 (1 Blender unit = 1 m)") # Optionally lower the subdivision level (the file's own level gives ~900k triangles in the hull). if subsurf_override is not None: for o in bpy.data.objects: for m in o.modifiers: if m.type == "SUBSURF": m.levels = subsurf_override m.render_levels = subsurf_override log("subsurf level overridden in memory to", subsurf_override) depsgraph = bpy.context.evaluated_depsgraph_get() sources = [o for o in bpy.data.objects if o.type == "MESH"] log("mesh objects in file:", len(sources)) # Whole-model bounds before anything is changed (the numbers the user quoted: 3.27 x 6.99 x 2.51 m). all_pts = [] for o in sources: ev = o.evaluated_get(depsgraph) me = ev.to_mesh() all_pts += [o.matrix_world @ v.co for v in me.vertices] ev.to_mesh_clear() gmn = Vector((min(p.x for p in all_pts), min(p.y for p in all_pts), min(p.z for p in all_pts))) gmx = Vector((max(p.x for p in all_pts), max(p.y for p in all_pts), max(p.z for p in all_pts))) size = gmx - gmn log("model size (m): width %.3f length %.3f height %.3f" % (size.x, size.y, size.z)) # 1) Applied, world-space copies of every mesh in a scratch collection. scratch = bpy.data.collections.new("_export_scratch") scene.collection.children.link(scratch) copies = {} for o in sources: copies[o.name] = evaluated_copy(o, depsgraph, scratch, "tmp_" + o.name) bpy.context.view_layer.update() # Pivots that depend on the source geometry, taken before the copies are modified. def src_bbox(name): return world_bbox(bpy.data.objects[name]) lid_mn, lid_mx = src_bbox("Canopy_Lid") hatch_mn, hatch_mx = src_bbox("Hatch_Lid") pivots = {} for part, objs, rule, role in SEPARATE_PARTS: o = bpy.data.objects[objs[0]] if rule == "zero": p = Vector((0.0, 0.0, 0.0)) elif rule == "origin": p = o.matrix_world.translation.copy() elif rule == "bbox_center": mn, mx = world_bbox(o) p = (mn + mx) * 0.5 elif rule == "lid_hinge": p = Vector((0.0, lid_mx.y, lid_mn.z)) # rear (+Y is aft) and bottom edge of the lid elif rule == "hatch_hinge": p = Vector((0.0, hatch_mx.y, hatch_mn.z)) # aft, bottom edge of the roof hatch else: die("unknown pivot rule " + rule) pivots[part] = p pivots[BODY_NAME] = Vector((0.0, 0.0, 0.0)) # 2) Bake transforms (fixes negative scales), remap materials, group, join. for c in copies.values(): apply_transforms(c) mat_cache = {} new_materials = {} for c in copies.values(): remap_materials(c, mat_cache, new_materials) separate_sources = {n for _, objs, _, _ in SEPARATE_PARTS for n in objs} # `copies` is keyed by the original object name. body_names = sorted(n for n in copies if n not in separate_sources) groups = [(BODY_NAME, body_names, "static")] for part, objs, rule, role in SEPARATE_PARTS: groups.append((part, list(objs), role)) body_objs = [copies[n] for n in body_names] log("body merges %d objects; %d separate parts" % (len(body_objs), len(SEPARATE_PARTS))) parts_out = [] total_tris = 0 joined_objects = [] for name, src_names, role in groups: joined = join_objects([copies[n] for n in src_names], "Kazah_" + name, scratch) # The source file keeps material slots no face uses (e.g. the tank lamps' crystal slot on the hull). # Unreal drops unused slots on import, so drop them here too and the manifest lists exactly what exists. drop_unused_slots(joined) # Part-local space: subtract the pivot so the pivot becomes the asset origin. piv = pivots[name] translate_mesh(joined, -piv) joined.location = (0, 0, 0) joined.rotation_euler = (0, 0, 0) joined.scale = (1, 1, 1) bpy.context.view_layer.update() mn, mx = world_bbox(joined) # part-local, Blender axes tris = tri_count(joined) total_tris += tris mats = [m.name for m in joined.data.materials] lo, hi = bbox_ue_cm(mn, mx) parts_out.append({ "name": name, "role": role, "file": "Kazah_%s.fbx" % name, "asset": "SM_Kazah_%s" % name, "pivot_ue_cm": blender_to_ue_cm(piv), "pivot_blender_m": [round(piv.x, 5), round(piv.y, 5), round(piv.z, 5)], "local_bounds_ue_cm": {"min": lo, "max": hi}, "triangles": tris, "materials": mats, # slot order == slot order of the imported static mesh "nanite": name in NANITE_PARTS, "outward_face_ratio": round(outward_ratio(joined), 3), "source_objects": src_names, }) joined_objects.append(joined) # 3) Export one FBX per part. for joined, entry in zip(joined_objects, parts_out): path = os.path.join(out_dir, entry["file"]) export_fbx(joined, path) entry["fbx_bytes"] = os.path.getsize(path) log("exported %-18s %8d tris %8.1f MB pivot(ue cm) %s" % (entry["name"], entry["triangles"], entry["fbx_bytes"] / 1e6, entry["pivot_ue_cm"])) # 4) Manifest. mats_out = {} for cn, info in sorted(mat_cache.items()): pbr = info["pbr"] mats_out[cn] = dict(pbr, kind=classify_material(cn, pbr), source_name=info["source"].name if info["source"] else None) ue_min, ue_max = bbox_ue_cm(gmn, gmx) manifest = { "source_blend": src.replace("\\", "/"), "exported_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "axis_note": "ue = (-blender_y, -blender_x, blender_z) * 100 cm; vehicle origin = Blender world origin", "fbx_export": {"forward": FBX_FORWARD, "up": FBX_UP, "unit_scale": "Blender 1 unit = 1 m"}, "vehicle_bounds_ue_cm": {"min": ue_min, "max": ue_max, "size": [round(ue_max[i] - ue_min[i], 3) for i in range(3)]}, "vehicle_size_m": {"length": round(size.y, 4), "width": round(size.x, 4), "height": round(size.z, 4)}, "total_triangles": total_tris, "parts": parts_out, "materials": mats_out, } with open(os.path.join(out_dir, "kazah_manifest.json"), "w", encoding="utf-8") as f: json.dump(manifest, f, indent=2) log("wrote manifest;", len(parts_out), "parts,", len(mats_out), "materials,", total_tris, "triangles") # 5) The C++ layout table. write_layout_inl(manifest) # 6) Prove we did not touch the user's file. st1 = os.stat(src) if (st0.st_size, st0.st_mtime_ns) != (st1.st_size, st1.st_mtime_ns): die("the source .blend changed on disk during export. This script must never do that.") log("source .blend unchanged (size %d, mtime preserved)" % st1.st_size) log("done in %.1f s" % (time.time() - t_start)) def write_layout_inl(m): lines = [] lines.append("// GENERATED by Tools/blender/export_kazah.py from %s" % os.path.basename(m["source_blend"])) lines.append("// Do not edit by hand: re-run Tools/export_kazah.ps1. Included by Vehicles/KazahTender.cpp.") lines.append("//") lines.append("// Centimetres, Unreal axes (+X forward, +Y right, +Z up), relative to the Tender's origin, which is the") lines.append("// Blender world origin. Each part's mesh has its own pivot as the asset origin, so a part's component") lines.append("// sits at PivotCm and can be rotated about that point. Local bounds are those of the exported mesh") lines.append("// around its pivot, used by the automation tests to catch a bad re-export.") v = m["vehicle_bounds_ue_cm"] lines.append("") lines.append("static const FVector GKazahBoundsMinCm(%.3f, %.3f, %.3f);" % tuple(v["min"])) lines.append("static const FVector GKazahBoundsMaxCm(%.3f, %.3f, %.3f);" % tuple(v["max"])) lines.append("") lines.append("static const FKazahPartDef GKazahPartDefs[] =") lines.append("{") for p in m["parts"]: pv = p["pivot_ue_cm"] b = p["local_bounds_ue_cm"] lines.append("\t{ TEXT(\"%s\"), TEXT(\"%s\"), FVector(%.3f, %.3f, %.3f), FVector(%.3f, %.3f, %.3f), FVector(%.3f, %.3f, %.3f) }," % (p["name"], p["asset"], pv[0], pv[1], pv[2], b["min"][0], b["min"][1], b["min"][2], b["max"][0], b["max"][1], b["max"][2])) lines.append("};") lines.append("") os.makedirs(os.path.dirname(LAYOUT_INL), exist_ok=True) with open(LAYOUT_INL, "w", encoding="utf-8", newline="\n") as f: f.write("\n".join(lines)) log("wrote", LAYOUT_INL) try: main() except SystemExit: raise except Exception as e: # make any failure a non-zero exit with a readable line import traceback traceback.print_exc() die("unhandled exception: %r" % (e,))