from __future__ import annotations import json import math import sys from pathlib import Path import bpy from mathutils import Vector def arguments() -> tuple[Path, Path, Path, str, float]: if "--" not in sys.argv: raise RuntimeError("Expected Blender arguments after --") values = sys.argv[sys.argv.index("--") + 1 :] if len(values) != 5: raise RuntimeError( "Usage: convert-runewaker-actor.py " " " ) return Path(values[0]), Path(values[1]), Path(values[2]), values[3], float(values[4]) def triangle_count(obj: bpy.types.Object) -> int: if obj.type != "MESH": return 0 return sum(max(0, len(polygon.vertices) - 2) for polygon in obj.data.polygons) def material_image_key(material: bpy.types.Material) -> str: if material.use_nodes and material.node_tree is not None: for node in material.node_tree.nodes: if node.type == "TEX_IMAGE" and node.image is not None: return str(Path(bpy.path.abspath(node.image.filepath)).resolve(False)).casefold() return f"material:{material.name.casefold()}" def deduplicate_materials(objects: list[bpy.types.Object]) -> dict[str, int]: canonical: dict[str, bpy.types.Material] = {} replacements = 0 for obj in objects: for slot in obj.material_slots: material = slot.material if material is None: continue key = material_image_key(material) if key in canonical: if slot.material != canonical[key]: slot.material = canonical[key] replacements += 1 else: canonical[key] = material material["semanticRole"] = "opaque" material["sourceFormat"] = "RuneWaker DDS" return {"unique": len(canonical), "replacements": replacements} def three_bounds(objects: list[bpy.types.Object]) -> tuple[list[float], list[float]]: low = Vector((math.inf, math.inf, math.inf)) high = Vector((-math.inf, -math.inf, -math.inf)) for obj in objects: for corner in obj.bound_box: point = obj.matrix_world @ Vector(corner) for axis in range(3): low[axis] = min(low[axis], point[axis]) high[axis] = max(high[axis], point[axis]) if not all(math.isfinite(value) for value in (*low, *high)): raise RuntimeError("Actor conversion produced no finite mesh bounds") return ( [round(low.x, 5), round(low.z, 5), round(-high.y, 5)], [round(high.x, 5), round(high.z, 5), round(-low.y, 5)], ) def main() -> None: source_obj, output_glb, report_file, asset_id, unit_scale = arguments() output_glb.parent.mkdir(parents=True, exist_ok=True) report_file.parent.mkdir(parents=True, exist_ok=True) bpy.ops.object.select_all(action="SELECT") bpy.ops.object.delete(use_global=False) result = bpy.ops.wm.obj_import( filepath=str(source_obj), forward_axis="NEGATIVE_Z", up_axis="Y", global_scale=unit_scale, use_split_objects=True, use_split_groups=True, validate_meshes=True, ) if "FINISHED" not in result: raise RuntimeError(f"OBJ import failed: {result}") meshes = sorted( (obj for obj in bpy.context.scene.objects if obj.type == "MESH"), key=lambda obj: obj.name.casefold(), ) if not meshes: raise RuntimeError("OBJ import produced no actor meshes") for obj in meshes: obj["sourceFormat"] = "RuneWaker ROS via preserved OBJ bridge" obj["sourceUnitScaleMeters"] = unit_scale obj.select_set(True) material_stats = deduplicate_materials(meshes) bounds_min, bounds_max = three_bounds(meshes) triangles = sum(triangle_count(obj) for obj in meshes) ground_offset = round(-bounds_min[1], 5) height = max(0.1, bounds_max[1] - bounds_min[1]) width = max(bounds_max[0] - bounds_min[0], bounds_max[2] - bounds_min[2]) bpy.context.view_layer.objects.active = meshes[0] result = bpy.ops.export_scene.gltf( filepath=str(output_glb), export_format="GLB", use_selection=True, export_yup=True, export_apply=False, export_animations=False, export_materials="EXPORT", export_cameras=False, export_lights=False, export_extras=True, ) if "FINISHED" not in result: raise RuntimeError(f"GLB export failed: {result}") report = { "schemaVersion": 1, "assetId": asset_id, "status": "static-authentic-model", "source": source_obj.name, "coordinateTransform": { "nativeBridge": "three(x,y,z)=(runewaker.x,runewaker.y,-runewaker.z)", "metersPerSourceUnit": unit_scale, }, "meshObjects": len(meshes), "triangles": triangles, "bounds": {"min": bounds_min, "max": bounds_max}, "materials": material_stats, "runtime": { "groundOffset": ground_offset, "labelHeight": round(height + 0.35, 5), "markerRadius": round(max(0.4, min(3.0, width * 0.32)), 5), }, "animations": [], "warnings": [ "The preserved ROS bridge exposes static geometry only; HealerMan supplies procedural movement and combat motion." ], } report_file.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") print(json.dumps(report, indent=2)) if __name__ == "__main__": main()