Files
healer-man/scripts/runewaker-pipeline/blender/convert-runewaker.py
T
2026-10-06 22:25:34 -04:00

507 lines
20 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
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("<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 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()