353 lines
13 KiB
Python
353 lines
13 KiB
Python
"""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("<II", handle.read(8))
|
|
if json_type != 0x4E4F534A:
|
|
raise RuntimeError(f"{file.name} has no leading GLB JSON chunk")
|
|
document = json.loads(handle.read(json_length).decode("utf-8").rstrip("\x00 "))
|
|
accessors = document.get("accessors", [])
|
|
count = 0
|
|
for mesh in document.get("meshes", []):
|
|
for primitive in mesh.get("primitives", []):
|
|
if primitive.get("mode", 4) != 4:
|
|
raise RuntimeError(f"{file.name} contains a non-triangle primitive")
|
|
accessor_index = primitive.get("indices")
|
|
if accessor_index is None:
|
|
accessor_index = primitive.get("attributes", {}).get("POSITION")
|
|
if accessor_index is None:
|
|
raise RuntimeError(f"{file.name} contains a primitive without geometry")
|
|
count += int(accessors[accessor_index]["count"]) // 3
|
|
return count
|
|
|
|
|
|
def blender_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("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()
|