"""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 from array import array import json import math import os import struct import sys from pathlib import Path import bpy from mathutils import Vector sys.path.insert(0, str(Path(__file__).resolve().parent)) from native_material_alpha import native_alpha_action, native_material_identity 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 material_image(material: bpy.types.Material) -> bpy.types.Image | None: if not material.use_nodes or material.node_tree is None: return None for node in material.node_tree.nodes: if node.type == "TEX_IMAGE" and node.image is not None: return node.image return None def image_alpha_role(image: bpy.types.Image | None) -> str: """Classify source alpha without turning fully opaque RGBA textures translucent.""" if image is None or image.channels < 4: return "opaque" pixels = array("f", [0.0]) * len(image.pixels) image.pixels.foreach_get(pixels) alpha_values = pixels[3::image.channels] if min(alpha_values, default=1.0) >= 0.999: return "opaque" if any(0.001 < alpha < 0.999 for alpha in alpha_values): return "blend" return "cutout" def apply_material_alpha_role( material: bpy.types.Material, image: bpy.types.Image | None, role: str, threshold: float = 0.5, ) -> str: if role == "opaque" or image is None or material.node_tree is None: return role image_node = next( node for node in material.node_tree.nodes if node.type == "TEX_IMAGE" and node.image == image ) principled = next( (node for node in material.node_tree.nodes if node.type == "BSDF_PRINCIPLED"), None, ) if principled is None: return role alpha_output = image_node.outputs.get("Alpha") alpha_input = principled.inputs.get("Alpha") if alpha_output is None or alpha_input is None: return role if role == "blend": if not alpha_input.is_linked: material.node_tree.links.new(alpha_output, alpha_input) material.blend_method = "BLEND" if hasattr(material, "surface_render_method"): material.surface_render_method = "BLENDED" else: alpha_clip = material.node_tree.nodes.new("ShaderNodeMath") alpha_clip.name = "RuneWaker Alpha Clip" alpha_clip.operation = "GREATER_THAN" alpha_clip.inputs[1].default_value = threshold material.node_tree.links.new(alpha_output, alpha_clip.inputs[0]) material.node_tree.links.new(alpha_clip.outputs[0], alpha_input) material.blend_method = "CLIP" material.alpha_threshold = threshold return role def preserve_material_alpha( material: bpy.types.Material, source_report: dict[str, object] | None, ) -> tuple[str, dict[str, object] | None, dict[str, object]]: image = material_image(material) role = image_alpha_role(image) image_path = bpy.path.abspath(image.filepath) if image is not None else "" identity = native_material_identity(material.name, image_path, source_report) decision = native_alpha_action(identity, role) action = str(decision.get("action", "unsupported")) if action == "pixel-alpha-heuristic": applied_role = apply_material_alpha_role(material, image, role) decision = { "action": applied_role, "reason": decision.get("reason", "no-verified-native-material-match"), } elif action in {"blend", "mask", "additive"}: applied_role = apply_material_alpha_role( material, image, "cutout" if action == "mask" else "blend", float(decision.get("threshold", 0.5)), ) decision = {**decision, "applied": applied_role} if identity is not None: material["nativeSourceModel"] = identity["sourceModel"] material["nativeSourceResourceSha256"] = identity["sourceResourceSha256"] material["nativeMaterialIndex"] = identity["materialIndex"] material["nativeSourceTexture"] = identity["sourceTexture"] material["nativeAlphaPolicy"] = str(decision.get("reason", action)) lighting = identity.get("lightingType") lighting_name = lighting.get("name") if isinstance(lighting, dict) else None if isinstance(lighting_name, str) and lighting_name.strip(): material["nativeLightingType"] = lighting_name.strip().lower() elif "nativeLightingType" in material: del material["nativeLightingType"] if action == "additive": material["nativeBlendMode"] = "additive" elif "nativeBlendMode" in material: del material["nativeBlendMode"] return role, identity, decision def deduplicate_materials( objects: list[bpy.types.Object], source_report: dict[str, object] | None, ) -> dict[str, object]: canonical: dict[str, bpy.types.Material] = {} replacements = 0 native_alpha_rows: dict[int, dict[str, object]] = {} for obj in objects: for slot in obj.material_slots: material = slot.material if material is None: continue alpha_role, identity, decision = preserve_material_alpha(material, source_report) semantics_key = ( repr((identity["sourceModel"], identity["sourceResourceSha256"], identity["materialIndex"], identity["sourceTexture"], sorted(identity["alphaBlendParameters"].items()))) if identity is not None else "pixel-alpha-heuristic:" + alpha_role ) key = material_image_key(material) + "|" + semantics_key if identity is not None: native_alpha_rows[identity["materialIndex"]] = { "material": identity["materialName"], "materialIndex": identity["materialIndex"], "sourceModel": identity["sourceModel"], "sourceResourceSha256": identity["sourceResourceSha256"], "sourceTexture": identity["sourceTexture"], "stagedTexture": identity["stagedTexture"], "materialType": identity["materialType"], "lightingType": identity["lightingType"], "alphaBlendParameters": identity["alphaBlendParameters"], "sourceImageAlphaRole": alpha_role, "conversionPolicy": decision, } if key in canonical: if slot.material != canonical[key]: slot.material = canonical[key] replacements += 1 else: canonical[key] = material material["semanticRole"] = decision.get("action", alpha_role) material["sourceFormat"] = "RuneWaker DDS" source_model = source_report.get("sourceModel") if isinstance(source_report, dict) else None source_hash = source_report.get("sourceResourceSha256") if isinstance(source_report, dict) else None native_alpha_materials = [native_alpha_rows[index] for index in sorted(native_alpha_rows)] return { "unique": len(canonical), "replacements": replacements, "nativeAlpha": { "sourceModel": source_model, "sourceResourceSha256": source_hash, "matchedMaterialCount": len(native_alpha_materials), "materials": native_alpha_materials, "unsupportedPolicies": [ row for row in native_alpha_materials if row["conversionPolicy"].get("action") == "unsupported" ], }, } 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) 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 ) 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, source_report) 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)) 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, "resourceSha256": source_report.get("sourceResourceSha256") 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()