""" Kazah Tender meshes (module of the import runner, see import_assets.py). Imports the exported FBX parts from SourceArt/Kazah into /Game/Vehicles/Kazah as static meshes whose pivot is the asset origin (cm, +X forward), assigns the materials made by assets_10_kazah_materials, and checks the imported bounds against the exporter's prediction. Safe to re-run: a mesh is re-imported only when its FBX changed (SHA-256 recorded in Saved/ImportState/kazah_import.json) or --force is given. """ import json import os import unreal GAME_DIR = "/Game/Vehicles/Kazah" MAT_DIR = GAME_DIR + "/Materials" STATE_NAME = "kazah_import" IMPORT_SCHEMA = 3 # bump when the import options below change, so existing meshes are re-imported def material_instances(ctx, manifest): """The instances the materials module made this run, or, when this module runs alone, loaded by path.""" shared = ctx.shared.get("kazah_instances") if shared: return shared out = {} for name in manifest["materials"]: mi_name = "MI_" + name[3:] if name.startswith("KZ_") else "MI_" + name mi = ctx.eal.load_asset(MAT_DIR + "/" + mi_name) if mi is None: raise RuntimeError("material %s/%s is missing: run assets_10_kazah_materials first" % (MAT_DIR, mi_name)) out[name] = mi return out def import_part(ctx, part, state, src_dir): EAL = ctx.eal fbx = os.path.join(src_dir, part["file"]) if not os.path.isfile(fbx): raise RuntimeError("missing %s - run Tools/export_kazah.ps1 first" % fbx) asset_path = GAME_DIR + "/" + part["asset"] digest = ctx.sha256(fbx) prev = state.get(part["asset"], {}) up_to_date = (not ctx.force and EAL.does_asset_exist(asset_path) and prev.get("sha256") == digest and prev.get("schema") == IMPORT_SCHEMA and prev.get("nanite") == part["nanite"]) if up_to_date: ctx.log("mesh %-24s up to date (FBX unchanged)" % part["asset"]) return EAL.load_asset(asset_path), False ctx.log("importing %s (%.1f MB)%s ..." % (part["file"], os.path.getsize(fbx) / 1e6, ", Nanite" if part["nanite"] else "")) # Always import into a fresh asset. Re-importing over an existing static mesh makes the classic FBX importer # fall back on the import settings stored in that asset, which silently overrode the axis options below and # flipped the model 90 degrees on the second run. Deleting first makes every run behave like the first. if EAL.does_asset_exist(asset_path): state.pop(part["asset"], None) if not EAL.delete_asset(asset_path): raise RuntimeError("could not delete the old " + asset_path) ui = unreal.FbxImportUI() ui.set_editor_property("import_mesh", True) ui.set_editor_property("import_as_skeletal", False) ui.set_editor_property("import_animations", False) ui.set_editor_property("import_materials", False) # we build our own, see assets_10_kazah_materials ui.set_editor_property("import_textures", False) ui.set_editor_property("mesh_type_to_import", unreal.FBXImportType.FBXIT_STATIC_MESH) sm = ui.get_editor_property("static_mesh_import_data") sm.set_editor_property("combine_meshes", True) # one mesh per file sm.set_editor_property("convert_scene", True) # FBX (Blender, -Y forward / Z up) -> Unreal axes sm.set_editor_property("force_front_x_axis", True) # nose along +X, not -Y sm.set_editor_property("convert_scene_unit", True) # FBX centimetres -> Unreal centimetres sm.set_editor_property("import_uniform_scale", 1.0) sm.set_editor_property("transform_vertex_to_absolute", True) sm.set_editor_property("bake_pivot_in_vertex", False) sm.set_editor_property("normal_import_method", unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS) sm.set_editor_property("remove_degenerates", True) sm.set_editor_property("generate_lightmap_u_vs", False) # no baked lighting in this project sm.set_editor_property("auto_generate_collision", False) # M0 parts have no collision sm.set_editor_property("build_nanite", bool(part["nanite"])) task = unreal.AssetImportTask() task.set_editor_property("filename", fbx) task.set_editor_property("destination_path", GAME_DIR) task.set_editor_property("destination_name", part["asset"]) task.set_editor_property("replace_existing", True) task.set_editor_property("replace_existing_settings", True) task.set_editor_property("automated", True) task.set_editor_property("save", False) # Naming a factory makes AssetTools use the classic FBX importer rather than Interchange, whose options # are not scriptable in the same way. The options object above is what that importer reads. task.set_editor_property("factory", unreal.FbxFactory()) task.set_editor_property("options", ui) ctx.tools.import_asset_tasks([task]) imported = [str(p) for p in task.get_editor_property("imported_object_paths")] if not imported: raise RuntimeError("import of %s produced no asset" % part["file"]) asset = EAL.load_asset(asset_path) if asset is None: raise RuntimeError("expected %s after import, got %s" % (asset_path, imported)) state[part["asset"]] = {"sha256": digest, "schema": IMPORT_SCHEMA, "nanite": part["nanite"]} return asset, True def assign_materials(ctx, mesh, part, instances): """Slot names come from the FBX material names (== manifest names). Fall back to manifest order.""" slots = list(mesh.get_editor_property("static_materials")) wanted = part["materials"] if len(slots) != len(wanted): unreal.log_warning("[import] %s has %d slots, manifest lists %d" % (part["asset"], len(slots), len(wanted))) for i, slot in enumerate(slots): slot_name = str(slot.get_editor_property("material_slot_name")) key = slot_name if slot_name in instances else (wanted[i] if i < len(wanted) else None) if key is None or key not in instances: ctx.fail("%s slot %d '%s' has no matching material" % (part["asset"], i, slot_name)) continue # Skip when already right: set_material re-runs the mesh build, which for the 970k-triangle Nanite hull # costs far longer than everything else in this script. if mesh.get_material(i) != instances[key]: mesh.set_material(i, instances[key]) def check_bounds(ctx, mesh, part): """The exporter predicts the imported bounds from first principles; the importer's axis conversion has to agree. A mismatch means a flipped axis or a wrong scale and is a hard failure.""" b = mesh.get_bounds() o, e = b.origin, b.box_extent got_min = [o.x - e.x, o.y - e.y, o.z - e.z] got_max = [o.x + e.x, o.y + e.y, o.z + e.z] exp = part["local_bounds_ue_cm"] # Half a centimetre: tight enough to tell the canopy lid's slightly lopsided Y extent (-89.4 / +89.9) from its # mirror image (-89.9 / +89.4), i.e. it also proves the import did not mirror the model left-right. tol = 0.5 ok = all(abs(got_min[i] - exp["min"][i]) <= tol and abs(got_max[i] - exp["max"][i]) <= tol for i in range(3)) ctx.log("bounds %-24s got min %s max %s expected min %s max %s %s" % ( part["asset"], [round(v, 1) for v in got_min], [round(v, 1) for v in got_max], [round(v, 1) for v in exp["min"]], [round(v, 1) for v in exp["max"]], "OK" if ok else "MISMATCH")) if not ok: ctx.fail("%s imported with the wrong orientation or scale (see bounds line above)" % part["asset"]) return ok def run(ctx): src_dir = os.path.join(ctx.project_dir, "SourceArt", "Kazah") manifest_path = os.path.join(src_dir, "kazah_manifest.json") if not os.path.isfile(manifest_path): raise RuntimeError("no manifest at %s - run Tools/export_kazah.ps1 first" % manifest_path) with open(manifest_path, "r", encoding="utf-8") as f: manifest = json.load(f) ctx.eal.make_directory(GAME_DIR) instances = material_instances(ctx, manifest) state = ctx.load_state(STATE_NAME) for part in manifest["parts"]: mesh, fresh = import_part(ctx, part, state, src_dir) assign_materials(ctx, mesh, part, instances) if not check_bounds(ctx, mesh, part): # Do not keep (or remember) a wrongly oriented mesh: the next run must import it again. state.pop(part["asset"], None) ctx.eal.delete_asset(GAME_DIR + "/" + part["asset"]) continue ctx.eal.save_loaded_asset(mesh) ctx.log("mesh %-24s %7d tris %d slots%s" % (part["asset"], mesh.get_num_triangles(0) if hasattr(mesh, "get_num_triangles") else -1, len(mesh.get_editor_property("static_materials")), " (imported now)" if fresh else "")) ctx.save_state(STATE_NAME, state)