Files
healer-man/scripts/dungeon-pipeline/blender/convert-dungeon.py
T
2026-08-14 15:56:39 -04:00

743 lines
33 KiB
Python

"""Recipe-driven wow.export OBJ -> browser GLB conversion (Blender 5+)."""
from __future__ import annotations
import json
import math
import os
import re
import shutil
import struct
import sys
import builtins
from contextlib import contextmanager
from importlib import import_module
from pathlib import Path
from typing import IO, Iterator
import bpy
import bmesh
from mathutils import Vector
def texture_path_key(value: str | Path) -> str:
return os.path.normcase(os.path.normpath(str(Path(value).resolve(strict=False))))
def mtl_texture_path_corrections(source_dir: Path) -> list[dict[str, str]]:
"""Recover exact, existing map_Kd paths truncated by wow.export's whitespace split."""
corrections: list[dict[str, str]] = []
for mtl_file in sorted(source_dir.rglob("*.mtl")):
for line_number, line in enumerate(
mtl_file.read_text(encoding="utf-8").splitlines(),
start=1,
):
fields = line.strip().split(maxsplit=1)
if len(fields) != 2 or fields[0] != "map_Kd":
continue
exact_reference = fields[1].strip()
split_reference = exact_reference.split(maxsplit=1)[0] if exact_reference else ""
if not split_reference or split_reference == exact_reference:
continue
split_path = mtl_file.parent / split_reference
exact_path = mtl_file.parent / exact_reference
if not exact_path.is_file():
candidates = sorted(
candidate
for candidate in mtl_file.parent.iterdir()
if candidate.is_file() and candidate.name.startswith(f"{split_reference} ")
)
if len(candidates) > 1:
names = ", ".join(repr(candidate.name) for candidate in candidates)
raise RuntimeError(
f"{mtl_file}:{line_number}: ambiguous whitespace-truncated texture "
f"{split_reference!r}; exact reference {exact_reference!r} is absent "
f"and candidates are {names}"
)
continue
corrections.append({
"mtl": mtl_file.relative_to(source_dir).as_posix(),
"line": str(line_number),
"splitReference": split_reference,
"exactReference": exact_reference,
"splitPath": split_path.relative_to(source_dir).as_posix(),
"exactPath": exact_path.relative_to(source_dir).as_posix(),
})
return corrections
def _wow_export_face_tail(fields: list[str]) -> list[int] | None:
if len(fields) != 4:
return None
valid_indices: list[int] = []
found_missing = False
for point in fields[1:]:
components = point.split("/")
is_wow_export_index = (
len(components) == 3
and all(re.fullmatch(r"[+-]?\d+", component) for component in components)
and len(set(components)) == 1
)
is_missing_index = len(components) == 3 and all(
component.casefold() == "nan" for component in components
)
if is_wow_export_index and not found_missing:
valid_indices.append(int(components[0]))
elif is_missing_index:
found_missing = True
else:
return None
return valid_indices if found_missing and len(valid_indices) in {1, 2} else None
def obj_nonfinite_face_corrections(source_dir: Path) -> list[dict[str, object]]:
"""Identify wow.export's terminal render-batch index remainder without inventing a face."""
corrections: list[dict[str, object]] = []
nonfinite = re.compile(r"(?i)(?:^|[\s/])[+-]?(?:nan|inf(?:inity)?)(?=$|[\s/])")
for obj_file in sorted(source_dir.rglob("*.obj")):
pending: dict[str, object] | None = None
group = ""
wow_export_header = False
with obj_file.open("r", encoding="utf-8") as stream:
for line_number, line in enumerate(stream, start=1):
fields = line.split()
if not fields or fields[0].startswith("#"):
if line_number == 1 and "wow.export" in line:
wow_export_header = True
continue
directive = fields[0]
if pending is not None:
if directive != "g":
raise RuntimeError(
f"{obj_file}:{pending['line']}: non-finite face is not the terminal "
"record of a wow.export mesh batch; source geometry cannot be repaired safely"
)
corrections.append(pending)
pending = None
if directive == "g":
group = fields[1] if len(fields) > 1 else ""
if not nonfinite.search(line):
continue
if not wow_export_header:
raise RuntimeError(
f"{obj_file}:{line_number}: non-finite OBJ data is not from a recognized "
"wow.export file and cannot be repaired safely"
)
valid_indices = _wow_export_face_tail(fields) if directive == "f" else None
if valid_indices is None:
raise RuntimeError(
f"{obj_file}:{line_number}: non-finite OBJ {directive!r} data cannot be "
"reconstructed without fabricating source geometry"
)
pending = {
"obj": obj_file.relative_to(source_dir).as_posix(),
"line": str(line_number),
"group": group,
"sourceRecord": line.strip(),
"validVertexIndices": valid_indices,
"danglingIndexCount": len(valid_indices),
"repair": "omit-terminal-incomplete-triangle",
}
if pending is not None:
corrections.append(pending)
return corrections
class _FullRemainderMtlLine(str):
"""Make wow.export's bare line.split() preserve the full map_Kd remainder."""
def split(self, sep=None, maxsplit=-1):
if sep is None and maxsplit == -1:
stripped = self.lstrip()
fields = str.split(stripped, None, 1)
if fields and fields[0] == "map_Kd":
if len(fields) == 2:
fields[1] = fields[1].strip()
return fields
return str.split(self, sep, maxsplit)
class _FullRemainderMtlStream:
def __init__(self, stream: IO[str]):
self.stream = stream
def __enter__(self):
self.stream.__enter__()
return self
def __exit__(self, exc_type, exc_value, traceback):
return self.stream.__exit__(exc_type, exc_value, traceback)
def __iter__(self):
return self
def __next__(self):
return _FullRemainderMtlLine(next(self.stream))
def __getattr__(self, name):
return getattr(self.stream, name)
class _SkippingObjLinesStream:
def __init__(self, stream, skipped_lines: set[int]):
self.stream = stream
self.skipped_lines = skipped_lines
self.line_number = 0
def __enter__(self):
self.stream.__enter__()
return self
def __exit__(self, exc_type, exc_value, traceback):
return self.stream.__exit__(exc_type, exc_value, traceback)
def __iter__(self):
return self
def __next__(self):
while True:
line = next(self.stream)
self.line_number += 1
if self.line_number not in self.skipped_lines:
return line
def __getattr__(self, name):
return getattr(self.stream, name)
@contextmanager
def corrected_wowobj_texture_loader(
importer_module: object,
source_dir: Path,
obj_face_corrections: list[dict[str, object]] | None = None,
) -> Iterator[list[dict[str, str]]]:
corrections = mtl_texture_path_corrections(source_dir)
obj_face_corrections = (
obj_nonfinite_face_corrections(source_dir)
if obj_face_corrections is None
else obj_face_corrections
)
skipped_obj_lines: dict[str, set[int]] = {}
for correction in obj_face_corrections:
key = texture_path_key(source_dir / str(correction["obj"]))
skipped_obj_lines.setdefault(key, set()).add(int(correction["line"]))
had_module_open = hasattr(importer_module, "open")
original_open = getattr(importer_module, "open", builtins.open)
def open_with_full_mtl_remainders(file, mode="r", *args, **kwargs):
stream = original_open(file, mode, *args, **kwargs)
is_path = isinstance(
file,
(str, bytes, os.PathLike),
)
file_suffix = Path(os.fsdecode(file)).suffix.casefold() if is_path else ""
if "b" not in mode and file_suffix == ".mtl":
return _FullRemainderMtlStream(stream)
obj_lines = skipped_obj_lines.get(texture_path_key(os.fsdecode(file))) if is_path else None
if "r" in mode and file_suffix == ".obj" and obj_lines:
return _SkippingObjLinesStream(stream, obj_lines)
return stream
importer_module.open = open_with_full_mtl_remainders
try:
yield corrections
finally:
if had_module_open:
importer_module.open = original_open
else:
delattr(importer_module, "open")
def arguments() -> tuple[Path, Path, Path, Path]:
if "--" not in sys.argv:
raise SystemExit("expected: -- RECIPE_JSON SOURCE_DIR OUTPUT_DIR WOW_EXPORT_ADDON")
values = [Path(value).resolve() for value in sys.argv[sys.argv.index("--") + 1 :]]
if len(values) != 4:
raise SystemExit(f"expected four arguments after --, received {len(values)}")
return values[0], values[1], values[2], values[3]
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:
"""Return the authoritative exported triangle count from GLB accessors."""
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", []):
mode = primitive.get("mode", 4)
if mode != 4:
raise RuntimeError(f"{file.name} contains non-triangle primitive mode {mode}")
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 indices or POSITION"
)
count += int(accessors[accessor_index]["count"]) // 3
return count
def 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:
if obj.type != "MESH":
continue
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 [round(value, 5) for value in low], [round(value, 5) for value in high]
def tag_material_roles(objects: list[bpy.types.Object]) -> dict[str, str]:
roles: dict[str, str] = {}
for obj in objects:
for slot in obj.material_slots:
material = slot.material
if material is None:
continue
name = material.name
lowered = name.lower()
role = "opaque"
if "water" in lowered or "liquid" in lowered or name.endswith("_B2"):
role = "blend"
elif name.endswith("_B1"):
role = "cutout"
elif name.endswith("_B4"):
role = "additive"
material["semanticRole"] = role
roles[name] = role
return dict(sorted(roles.items()))
def is_liquid_material(name: str) -> bool:
lowered = name.lower()
return "water" in lowered or "liquid" in lowered or name.endswith("_B2")
def reconstruct_wmo_liquids(source_dir: Path) -> int:
material = bpy.data.materials.get("DungeonWater_B2") or bpy.data.materials.new("DungeonWater_B2")
material.diffuse_color = (0.035, 0.18, 0.17, 0.76)
material["semanticRole"] = "blend"
tile_count = 0
for group_file in sorted((source_dir / "raw").rglob("*.wmo")):
if not re.search(r"_\d{3}\.wmo$", group_file.name, re.IGNORECASE):
continue
payload = group_file.read_bytes()
cursor = 0
while True:
chunk = payload.find(b"QILM", cursor)
if chunk < 0:
break
size = struct.unpack_from("<I", payload, chunk + 4)[0]
offset = chunk + 8
vert_x, vert_y, tile_x, tile_y = struct.unpack_from("<4I", payload, offset)
corner_x, corner_y, corner_z = struct.unpack_from("<3f", payload, offset + 16)
vertex_offset = offset + 30
heights = []
for index in range(vert_x * vert_y):
_, height = struct.unpack_from("<If", payload, vertex_offset + index * 8)
heights.append(height if abs(height) > 1e-5 else corner_z)
flags_offset = vertex_offset + vert_x * vert_y * 8
flags = payload[flags_offset : flags_offset + tile_x * tile_y]
vertices: list[tuple[float, float, float]] = []
faces: list[tuple[int, int, int, int]] = []
unit = 4.166666666666667
for y in range(tile_y):
for x in range(tile_x):
if flags[y * tile_x + x] & 0x0F == 0x0F:
continue
indices = (y * vert_x + x, y * vert_x + x + 1, (y + 1) * vert_x + x + 1, (y + 1) * vert_x + x)
base = len(vertices)
vertices.extend([
(corner_x + x * unit, corner_y + y * unit, heights[indices[0]]),
(corner_x + (x + 1) * unit, corner_y + y * unit, heights[indices[1]]),
(corner_x + (x + 1) * unit, corner_y + (y + 1) * unit, heights[indices[2]]),
(corner_x + x * unit, corner_y + (y + 1) * unit, heights[indices[3]]),
])
faces.append((base, base + 1, base + 2, base + 3))
if faces:
mesh = bpy.data.meshes.new(f"Liquid_{group_file.stem}_{cursor}")
mesh.from_pydata(vertices, [], faces)
mesh.materials.append(material)
obj = bpy.data.objects.new(mesh.name, mesh)
obj["source"] = "WMO_MLIQ"
bpy.context.scene.collection.objects.link(obj)
tile_count += len(faces)
cursor = offset + size
return tile_count
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 collision_copy(
source: bpy.types.Object,
slug: str,
index: int,
triangle_budget: int = 100_000,
) -> 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)
select_only([copy])
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
liquid_materials = {
slot_index for slot_index, material in enumerate(copy.data.materials)
if material is not None and is_liquid_material(material.name)
}
if liquid_materials:
mesh = bmesh.new()
mesh.from_mesh(copy.data)
bmesh.ops.delete(
mesh,
geom=[face for face in mesh.faces if face.material_index in liquid_materials],
context="FACES",
)
mesh.to_mesh(copy.data)
mesh.free()
copy.data.materials.clear()
before = triangles(copy)
if before > triangle_budget:
modifier = copy.modifiers.new("CollisionDecimate", "DECIMATE")
modifier.ratio = triangle_budget / before
modifier.use_collapse_triangulate = True
bpy.ops.object.modifier_apply(modifier=modifier.name)
copy["collider"] = "trimesh"
copy["sourceChunk"] = source.get("sourcePath", source.name)
return copy
def is_adt_wmo_architecture(obj: bpy.types.Object) -> bool:
"""Keep placed WMO shells in collision without promoting decorative M2s."""
found_wmo_parent = False
parent = obj.parent
while parent is not None:
base_name = re.sub(r"\.\d{3}$", "", parent.name).lower()
if base_name == "doodads":
return False
if base_name == "wmos":
found_wmo_parent = True
parent = parent.parent
return found_wmo_parent
def normalize_adt_wmo_child_transforms(
placement_root: bpy.types.Object,
source_path: str,
) -> list[dict[str, object]]:
"""Repair wow.export state leakage when an M2 and WMO share an OBJ basename.
A placed WMO's parent empty owns its map-space placement. The direct WMO OBJ
mesh is therefore canonical at local origin, with only the OBJ importer's
Blender Z-up correction. Decorative M2 branches are not direct children of
these ``* parent`` WMO placement empties and are intentionally untouched.
"""
corrections: list[dict[str, object]] = []
for placement_container in placement_root.children:
if re.sub(r"\.\d{3}$", "", placement_container.name).lower() != "wmos":
continue
for placement_parent in placement_container.children:
if not re.sub(r"\.\d{3}$", "", placement_parent.name).lower().endswith(" parent"):
continue
for child in placement_parent.children:
if child.type != "MESH":
continue
previous = {
"location": list(child.location),
"rotationEuler": list(child.rotation_euler),
"scale": list(child.scale),
}
is_canonical = (
max(abs(value) for value in child.location) <= 1e-4
and abs(child.rotation_euler.x - math.pi / 2) <= 1e-4
and abs(child.rotation_euler.y) <= 1e-4
and abs(child.rotation_euler.z) <= 1e-4
and max(abs(value - 1) for value in child.scale) <= 1e-4
)
if is_canonical:
continue
child.location = (0.0, 0.0, 0.0)
child.rotation_euler = (math.pi / 2, 0.0, 0.0)
child.scale = (1.0, 1.0, 1.0)
corrections.append({
"object": child.name,
"mesh": child.data.name,
"placementParent": placement_parent.name,
"source": source_path,
"previous": previous,
"normalized": {
"location": [0.0, 0.0, 0.0],
"rotationEuler": [math.pi / 2, 0.0, 0.0],
"scale": [1.0, 1.0, 1.0],
},
})
return corrections
def main() -> None:
recipe_file, source_dir, output_dir, addon_parent = arguments()
recipe = json.loads(recipe_file.read_text(encoding="utf-8"))
slug = recipe["slug"]
extraction_result_file = source_dir / "automation-result.json"
extraction_result = json.loads(extraction_result_file.read_text(encoding="utf-8")) if extraction_result_file.is_file() else {}
source_omissions = extraction_result.get("sourceOmissions", [])
source_fallbacks = extraction_result.get("sourceFallbacks", [])
environment_mode = extraction_result.get("environmentMode", recipe["environmentMode"])
if not (addon_parent / "io_scene_wowobj").is_dir():
raise FileNotFoundError(addon_parent / "io_scene_wowobj")
# Root WMO/ADT OBJs carry placement CSVs that pull dependency OBJs in once.
obj_files = sorted(file for file in source_dir.glob("adt_*.obj") if re.fullmatch(r"adt_\d+_\d+\.obj", file.name))
if not obj_files:
obj_files = sorted(source_dir.glob("*.obj"))
if not obj_files:
raise RuntimeError("extraction produced no OBJ files to assemble")
output_dir.mkdir(parents=True, exist_ok=True)
for stale_collision in output_dir.glob(f"{slug}-collision*.glb"):
stale_collision.unlink()
sys.path.insert(0, str(addon_parent))
from io_scene_wowobj import Settings
wowobj_importer = import_module("io_scene_wowobj.import_wowobj")
importWoWOBJ = wowobj_importer.importWoWOBJ
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
settings = Settings(
useAlpha=True, createVertexGroups=False, allowDuplicates=False,
importWMO=True, importWMOSets=True, importM2=True, importGOBJ=False,
importTextures=True, useTerrainBlending=True, createEmissiveMaterials=False,
createDoodadSetCollections=False, importLiquid=True, importUVAnimations=False,
)
settings._import_cache_cleared = False
architectural: list[bpy.types.Object] = []
imported_sources: list[str] = []
placement_coordinate_correction_count = 0
wmo_child_transform_corrections: list[dict[str, object]] = []
obj_face_corrections = obj_nonfinite_face_corrections(source_dir)
with corrected_wowobj_texture_loader(
wowobj_importer,
source_dir,
obj_face_corrections,
) as texture_path_corrections:
for obj_file in obj_files:
before = set(bpy.context.scene.objects)
is_adt = re.fullmatch(r"adt_\d+_\d+\.obj", obj_file.name) is not None
if is_adt:
bpy.ops.wm.obj_import(filepath=str(obj_file), forward_axis="NEGATIVE_Z", up_axis="Y")
created_terrain = [obj for obj in bpy.context.scene.objects if obj not in before and obj.type == "MESH"]
architectural.extend(created_terrain)
placement_csv = obj_file.with_name(obj_file.stem + "_ModelPlacementInformation.csv")
if placement_csv.is_file():
placement_obj = obj_file.with_name(obj_file.stem + "_placements.obj")
placement_mtl = obj_file.with_name(obj_file.stem + "_placements.mtl")
placement_obj.write_text(
f"mtllib {placement_mtl.name}\no PlacementAnchor\n"
"v 0 0 0\nv 0.001 0 0\nv 0 0.001 0\n"
"vn 0 1 0\nvn 0 1 0\nvn 0 1 0\n"
"vt 0 0\nvt 0 0\nvt 0 0\n"
"g PlacementAnchor\nusemtl PlacementAnchor\n"
"f 1/1/1 2/2/2 3/3/3\n",
encoding="utf-8",
)
placement_mtl.write_text("newmtl PlacementAnchor\nKd 0 0 0\n", encoding="utf-8")
shutil.copyfile(
placement_csv,
placement_obj.with_name(placement_obj.stem + "_ModelPlacementInformation.csv"),
)
placement_root = importWoWOBJ(str(placement_obj), None, settings)
if placement_root is not None:
wmo_child_transform_corrections.extend(
normalize_adt_wmo_child_transforms(
placement_root,
obj_file.relative_to(source_dir).as_posix(),
)
)
# The wow.export ADT placement importer rotates its WMO and
# doodad containers into Y-up space. Blender's glTF exporter
# performs that conversion again, so retain Blender Z-up here.
for placement_container in placement_root.children:
base_name = re.sub(r"\.\d{3}$", "", placement_container.name)
if base_name in {"WMOs", "Doodads", "GameObjects"}:
placement_container.rotation_euler.x = 0.0
placement_coordinate_correction_count += 1
bpy.data.objects.remove(placement_root, do_unlink=True)
root = created_terrain[0] if created_terrain else None
else:
root = importWoWOBJ(str(obj_file), None, settings)
created = [obj for obj in bpy.context.scene.objects if obj not in before]
for obj in created:
obj["sourcePath"] = obj_file.relative_to(source_dir).as_posix()
if is_adt:
architectural.extend(
obj for obj in created
if obj.type == "MESH"
and obj not in created_terrain
and is_adt_wmo_architecture(obj)
)
if not is_adt and root is not None and root.type == "MESH":
architectural.append(root)
imported_sources.append(obj_file.relative_to(source_dir).as_posix())
liquid_tiles = reconstruct_wmo_liquids(source_dir)
visual = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"]
if not visual or not architectural:
raise RuntimeError("wow.export import did not create architectural mesh chunks")
material_roles = tag_material_roles(visual)
adt_liquid_meshes = sum(
1 for obj in visual
if any(slot.material is not None and "adt_liquid" in slot.material.name.lower() for slot in obj.material_slots)
)
low, high = bounds(visual)
visual_file = output_dir / f"{slug}-visual.glb"
export_glb(visual_file, visual)
visual_exported_count = glb_triangle_count(visual_file)
collision_files: list[dict[str, object]] = []
collision_groups: list[list[bpy.types.Object]] = []
collision_group: list[bpy.types.Object] = []
collision_group_triangles = 0
collision_total_triangle_target = 4_000_000
collision_mesh_triangle_target = min(
100_000,
max(4_000, collision_total_triangle_target // max(1, len(architectural))),
)
omitted_zero_triangle_collision_sources: list[str] = []
for source_index, source in enumerate(architectural):
collision = collision_copy(
source,
slug,
source_index,
collision_mesh_triangle_target,
)
count = triangles(collision)
if count == 0:
omitted_zero_triangle_collision_sources.append(
str(source.get("sourcePath", source.name))
)
bpy.data.objects.remove(collision, do_unlink=True)
continue
if collision_group and collision_group_triangles + count > 100_000:
collision_groups.append(collision_group)
collision_group = []
collision_group_triangles = 0
collision_group.append(collision)
collision_group_triangles += count
if collision_group:
collision_groups.append(collision_group)
for collision_index, collision_objects in enumerate(collision_groups):
suffix = "" if len(collision_groups) == 1 else f"-{collision_index:03d}"
file = output_dir / f"{slug}-collision{suffix}.glb"
export_glb(file, collision_objects, materials="NONE")
exported_count = glb_triangle_count(file)
if exported_count > 100_000:
raise RuntimeError(
f"{file.name} exceeds the 100000-triangle collision budget ({exported_count})"
)
collision_files.append({"file": file.name, "triangles": exported_count})
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": environment_mode,
"coordinateTransform": "three(x,y,z)=(-wow.x,wow.z,wow.y)",
"placementCoordinateCorrection": {
"applied": placement_coordinate_correction_count > 0,
"containerCount": placement_coordinate_correction_count,
"sourceConvention": "wow.export ADT placement containers are Y-up",
"conversionConvention": "Blender Z-up before glTF Y-up export",
"operation": "reset placement-container X rotation to 0 radians",
},
"wmoChildTransformCorrections": {
"count": len(wmo_child_transform_corrections),
"provenance": "wow.export M2/WMO same-basename import-state normalization",
"operation": "normalize direct placed-WMO OBJ child to local origin, X=pi/2 radians, unit scale",
"sources": wmo_child_transform_corrections,
},
"sources": imported_sources,
"texturePathCorrections": texture_path_corrections,
"objFaceCorrections": obj_face_corrections,
"visual": {"file": visual_file.name, "meshObjects": len(visual), "triangles": visual_exported_count, "bounds": {"min": low, "max": high}},
"collision": collision_files, "materialRoles": material_roles,
"collisionChunking": {
"maximumTrianglesPerChunk": 100_000,
"totalTriangleTarget": collision_total_triangle_target,
"perMeshTriangleTarget": collision_mesh_triangle_target,
"architecturalMeshes": len(architectural),
"chunks": len(collision_files),
},
"collisionOmissions": {
"zeroTriangleSources": omitted_zero_triangle_collision_sources,
},
"sourceOmissions": source_omissions,
"sourceFallbacks": source_fallbacks,
"liquids": {"adtMeshes": adt_liquid_meshes, "wmoTiles": liquid_tiles},
"editableSource": blend_file.name,
"warnings": (
(["No ADT liquid geometry was reconstructed; visual review is required."] if environment_mode == "adt-hybrid" and adt_liquid_meshes == 0 else [])
+ ([f"Normalized {placement_coordinate_correction_count} ADT placement containers to Blender Z-up before glTF export."] if placement_coordinate_correction_count else [])
+ ([f"Normalized {len(wmo_child_transform_corrections)} direct placed-WMO OBJ child transform(s) after wow.export same-basename import-state leakage: {', '.join(correction['object'] for correction in wmo_child_transform_corrections)}."] if wmo_child_transform_corrections else [])
+ ([f"Omitted {len(omitted_zero_triangle_collision_sources)} architectural sources with no collision triangles."] if omitted_zero_triangle_collision_sources else [])
+ [
f"Authored decorative source omission: {omission['path']} ({omission['reason']})"
for omission in source_omissions
]
+ [
f"Installed-client extraction fallback: {fallback['path']} ({fallback['reason']})"
for fallback in source_fallbacks
if fallback.get("provenance") != "client-extension-alias"
]
+ ([
f"Resolved {sum(1 for fallback in source_fallbacks if fallback.get('provenance') == 'client-extension-alias')} "
"legacy MDX placement reference(s) to canonical installed-client M2 paths; "
"exact alias provenance is retained in sourceFallbacks."
] if any(fallback.get("provenance") == "client-extension-alias" for fallback in source_fallbacks) else [])
),
}
(output_dir / "conversion-report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, indent=2))
if __name__ == "__main__":
main()