Files
2026-08-14 15:56:39 -04:00

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()