743 lines
33 KiB
Python
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()
|