forked from phenom/healer-man
507 lines
20 KiB
Python
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()
|