Files
healer-man/scripts/runewaker-pipeline/blender/convert-runewaker-actor.py
T
2026-08-14 15:56:39 -04:00

157 lines
5.5 KiB
Python

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 <scene.obj> <output.glb> "
"<report.json> <asset-id> <unit-scale>"
)
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()