"""Convert a RuneWaker ROS OBJ bridge export into HealerMan GLB assets. Expected invocation: blender --background --factory-startup --python convert-runewaker.py -- \ SOURCE_OBJ OUTPUT_DIR SLUG UNIT_SCALE """ from __future__ import annotations import json import math import os import struct import sys from pathlib import Path import bpy from mathutils import Vector COLLISION_CHUNK_TRIANGLE_LIMIT = 90_000 def arguments() -> tuple[Path, Path, str, float]: if "--" not in sys.argv: raise SystemExit("expected: -- SOURCE_OBJ OUTPUT_DIR SLUG UNIT_SCALE") values = sys.argv[sys.argv.index("--") + 1 :] if len(values) != 4: raise SystemExit(f"expected four arguments after --, received {len(values)}") source_obj = Path(values[0]).resolve() output_dir = Path(values[1]).resolve() slug = values[2] unit_scale = float(values[3]) if not source_obj.is_file(): raise FileNotFoundError(source_obj) if unit_scale <= 0: raise ValueError("UNIT_SCALE must be positive") return source_obj, output_dir, slug, unit_scale def select_only(objects: list[bpy.types.Object]) -> None: bpy.ops.object.select_all(action="DESELECT") for obj in objects: obj.hide_set(False) obj.hide_viewport = False obj.select_set(True) if objects: bpy.context.view_layer.objects.active = objects[0] def triangles(obj: bpy.types.Object) -> int: if obj.type != "MESH": return 0 obj.data.calc_loop_triangles() return len(obj.data.loop_triangles) def glb_triangle_count(file: Path) -> int: with file.open("rb") as handle: magic, version, _length = struct.unpack("<4sII", handle.read(12)) if magic != b"glTF" or version != 2: raise RuntimeError(f"{file.name} is not a glTF 2 GLB") json_length, json_type = struct.unpack(" 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("conversion produced no finite mesh bounds") return [float(value) for value in low], [float(value) for value in high] def three_bounds(objects: list[bpy.types.Object]) -> tuple[list[float], list[float]]: """Convert Blender Z-up bounds back to the GLB/Three.js Y-up convention.""" low, high = blender_bounds(objects) return ( [round(low[0], 5), round(low[2], 5), round(-high[1], 5)], [round(high[0], 5), round(high[2], 5), round(-low[1], 5)], ) 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 os.path.normcase( os.path.normpath(str(Path(bpy.path.abspath(node.image.filepath)).resolve(False))) ) 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 export_glb( file: Path, objects: list[bpy.types.Object], materials: str = "EXPORT", ) -> None: select_only(objects) result = bpy.ops.export_scene.gltf( filepath=str(file), export_format="GLB", use_selection=True, export_yup=True, export_apply=False, export_animations=False, export_materials=materials, export_cameras=False, export_lights=False, export_extras=True, ) if "FINISHED" not in result: raise RuntimeError(f"GLB export failed for {file.name}: {result}") def make_collision_copy( source: bpy.types.Object, slug: str, index: int, ) -> bpy.types.Object: copy = source.copy() copy.data = source.data.copy() copy.parent = None copy.matrix_world = source.matrix_world.copy() copy.name = f"COLLISION_{slug}_{index:03d}" bpy.context.scene.collection.objects.link(copy) copy.data.materials.clear() copy["collider"] = "trimesh" copy["sourceChunk"] = source.name copy["provisional"] = True return copy def collision_groups( objects: list[bpy.types.Object], ) -> list[tuple[list[bpy.types.Object], int]]: groups: list[tuple[list[bpy.types.Object], int]] = [] current: list[bpy.types.Object] = [] current_triangles = 0 for obj in objects: count = triangles(obj) if count <= 0: continue if count > COLLISION_CHUNK_TRIANGLE_LIMIT: select_only([obj]) modifier = obj.modifiers.new("CollisionDecimate", "DECIMATE") modifier.ratio = COLLISION_CHUNK_TRIANGLE_LIMIT / count modifier.use_collapse_triangulate = True bpy.ops.object.modifier_apply(modifier=modifier.name) count = triangles(obj) if current and current_triangles + count > COLLISION_CHUNK_TRIANGLE_LIMIT: groups.append((current, current_triangles)) current = [] current_triangles = 0 current.append(obj) current_triangles += count if current: groups.append((current, current_triangles)) return groups def render_topdown_preview( file: Path, objects: list[bpy.types.Object], ) -> None: low_values, high_values = blender_bounds(objects) low = Vector(low_values) high = Vector(high_values) center = (low + high) * 0.5 extent = high - low camera_data = bpy.data.cameras.new("ForsakenAbbeyPreviewCamera") camera = bpy.data.objects.new(camera_data.name, camera_data) bpy.context.scene.collection.objects.link(camera) camera.location = (center.x, center.y, high.z + max(extent.x, extent.y, 1.0)) camera.rotation_euler = (0.0, 0.0, 0.0) camera.rotation_euler = (Vector((0.0, 0.0, -1.0))).to_track_quat("-Z", "Y").to_euler() camera_data.type = "ORTHO" aspect = 16.0 / 9.0 camera_data.ortho_scale = max(extent.y, extent.x / aspect) * 1.08 scene = bpy.context.scene scene.camera = camera scene.render.engine = "BLENDER_WORKBENCH" scene.display.shading.light = "STUDIO" scene.display.shading.color_type = "MATERIAL" scene.display.shading.show_shadows = True scene.display.shading.show_cavity = True scene.display.shading.cavity_type = "WORLD" scene.render.resolution_x = 1600 scene.render.resolution_y = 900 scene.render.resolution_percentage = 100 scene.render.image_settings.file_format = "PNG" scene.render.filepath = str(file) bpy.ops.render.render(write_still=True) bpy.data.objects.remove(camera, do_unlink=True) bpy.data.cameras.remove(camera_data) def main() -> None: source_obj, output_dir, slug, unit_scale = arguments() output_dir.mkdir(parents=True, exist_ok=True) for stale in output_dir.glob(f"{slug}-collision*.glb"): stale.unlink() 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}") visual = sorted( (obj for obj in bpy.context.scene.objects if obj.type == "MESH"), key=lambda obj: obj.name.casefold(), ) if not visual: raise RuntimeError("OBJ import produced no mesh objects") for obj in visual: obj["sourcePath"] = source_obj.name obj["sourceFormat"] = "RuneWaker ROS via OBJ bridge" obj["sourceUnitScaleMeters"] = unit_scale material_stats = deduplicate_materials(visual) bounds_min, bounds_max = three_bounds(visual) source_triangles = sum(triangles(obj) for obj in visual) visual_file = output_dir / f"{slug}-visual.glb" export_glb(visual_file, visual) preview_file = output_dir / f"{slug}-preview.png" render_topdown_preview(preview_file, visual) collision_objects = [ make_collision_copy(source, slug, index) for index, source in enumerate(visual) ] groups = collision_groups(collision_objects) collision_files: list[dict[str, object]] = [] for index, (objects, expected_triangles) in enumerate(groups): suffix = "" if len(groups) == 1 else f"-{index:03d}" file = output_dir / f"{slug}-collision{suffix}.glb" export_glb(file, objects, materials="NONE") exported_triangles = glb_triangle_count(file) if exported_triangles > 100_000: raise RuntimeError( f"{file.name} exceeds HealerMan's collision budget ({exported_triangles})" ) collision_files.append( { "file": file.name, "triangles": exported_triangles, "sourceTriangleEstimate": expected_triangles, } ) blend_file = output_dir / f"{slug}.source.blend" bpy.ops.wm.save_as_mainfile(filepath=str(blend_file)) source_report_file = source_obj.parent / "export-report.json" source_report = ( json.loads(source_report_file.read_text(encoding="utf-8")) if source_report_file.is_file() else None ) report = { "schemaVersion": 1, "dungeonId": slug, "status": "review-required", "environmentMode": "runewaker-ros", "source": { "obj": source_obj.name, "report": source_report_file.name if source_report is not None else None, "model": source_report.get("sourceModel") if source_report else None, }, "coordinateTransform": { "nativeBridge": "three(x,y,z)=(runewaker.x,runewaker.y,-runewaker.z)", "unitScaleMeters": unit_scale, "calibration": "16.4977-unit fake_pc mesh height -> approximately 1.65 m", }, "visual": { "file": visual_file.name, "meshObjects": len(visual), "triangles": glb_triangle_count(visual_file), "sourceTriangles": source_triangles, "bounds": {"min": bounds_min, "max": bounds_max}, "materials": material_stats, }, "collision": collision_files, "collisionPolicy": { "kind": "provisional-trimesh-copy", "maximumTrianglesPerChunk": 100_000, "workingChunkTarget": COLLISION_CHUNK_TRIANGLE_LIMIT, "includesDecorativeMeshes": True, }, "preview": preview_file.name, "editableSource": blend_file.name, "warnings": [ "Collision is generated from all primary ROS meshes for the pilot; replace or filter decorative geometry after visual review.", "The primary ROS does not include every WDB-managed doodad, effect, or runtime placement.", ], } report_file = output_dir / "conversion-report.json" report_file.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") print(json.dumps(report, indent=2)) if __name__ == "__main__": main()