157 lines
5.5 KiB
Python
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()
|