1579 lines
68 KiB
Python
1579 lines
68 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
WoW 3.3.5 (WotLK, M2 v264) → GLTF 2.0 (.glb) converter.
|
||
|
||
Usage:
|
||
python m2_to_gltf.py path/to/Model.M2 [output_dir]
|
||
|
||
Produces <output_dir>/<ModelName>.glb with:
|
||
- Skinned mesh (all skin sections as primitives)
|
||
- Full bone hierarchy with bind poses
|
||
- All animation sequences as named GLTF animations
|
||
"""
|
||
|
||
import io
|
||
import json
|
||
import math
|
||
import os
|
||
import struct
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
try:
|
||
from PIL import Image as _PIL_Image
|
||
_PIL_OK = True
|
||
except ImportError:
|
||
_PIL_OK = False
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# BLP2 decoder → raw RGBA bytes (PIL required for DXT)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
def _blp_to_rgba(path: Path):
|
||
"""
|
||
Decode a BLP2 file to (width, height, rgba_bytes).
|
||
Supports palette (compression=1) and DXT (compression=2).
|
||
Returns None if decoding fails or PIL is unavailable for DXT.
|
||
"""
|
||
try:
|
||
data = path.read_bytes()
|
||
except OSError:
|
||
return None
|
||
|
||
if data[:4] not in (b'BLP2', b'BLP1'):
|
||
return None
|
||
|
||
# Header layout (C++ BlpStructure.h):
|
||
# signature(4) version(4) compression(1) alphaDepth(1) alphaCompression(1)
|
||
# mipLevels(1) width(4) height(4) offsets[16](64) sizes[16](64) = 148 bytes
|
||
compression = data[8] # 1=palette, 2=DXT, 3=raw RGBA
|
||
alpha_depth = data[9] # 0/1/4/8 bits
|
||
alpha_compr = data[10] # for DXT: 0=DXT1, 1=DXT3, 7=DXT3 variant
|
||
width = struct.unpack_from('<I', data, 12)[0]
|
||
height = struct.unpack_from('<I', data, 16)[0]
|
||
mip_offsets = struct.unpack_from('<16I', data, 20)
|
||
mip_sizes = struct.unpack_from('<16I', data, 84)
|
||
|
||
if width == 0 or height == 0 or not mip_offsets[0]:
|
||
return None
|
||
|
||
mip_data = data[mip_offsets[0]: mip_offsets[0] + mip_sizes[0]]
|
||
|
||
# ── Palette (compression == 1) ────────────────────────────────────────
|
||
if compression == 1:
|
||
# BGRA palette of 256 entries at offset 148
|
||
pal_raw = data[148: 148 + 1024]
|
||
n = width * height
|
||
indices = mip_data[:n]
|
||
rgba = bytearray(n * 4)
|
||
|
||
if alpha_depth == 8:
|
||
# Color indices followed immediately by alpha bytes
|
||
alpha_bytes = mip_data[n: n + n]
|
||
for i in range(n):
|
||
idx = indices[i]
|
||
b, g, r = pal_raw[idx*4], pal_raw[idx*4+1], pal_raw[idx*4+2]
|
||
a = alpha_bytes[i] if i < len(alpha_bytes) else 255
|
||
rgba[i*4], rgba[i*4+1], rgba[i*4+2], rgba[i*4+3] = r, g, b, a
|
||
elif alpha_depth == 4:
|
||
alpha_src = mip_data[n:]
|
||
for i in range(n):
|
||
idx = indices[i]
|
||
b, g, r = pal_raw[idx*4], pal_raw[idx*4+1], pal_raw[idx*4+2]
|
||
byte_idx = i >> 1
|
||
a4 = (alpha_src[byte_idx] >> (4 * (i & 1))) & 0xF if byte_idx < len(alpha_src) else 0xF
|
||
a = a4 | (a4 << 4)
|
||
rgba[i*4], rgba[i*4+1], rgba[i*4+2], rgba[i*4+3] = r, g, b, a
|
||
elif alpha_depth == 1:
|
||
alpha_src = mip_data[n:]
|
||
for i in range(n):
|
||
idx = indices[i]
|
||
b, g, r = pal_raw[idx*4], pal_raw[idx*4+1], pal_raw[idx*4+2]
|
||
byte_idx = i >> 3
|
||
bit = (alpha_src[byte_idx] >> (i & 7)) & 1 if byte_idx < len(alpha_src) else 1
|
||
a = 255 if bit else 0
|
||
rgba[i*4], rgba[i*4+1], rgba[i*4+2], rgba[i*4+3] = r, g, b, a
|
||
else:
|
||
# alpha_depth == 0: fully opaque
|
||
for i in range(n):
|
||
idx = indices[i]
|
||
b, g, r = pal_raw[idx*4], pal_raw[idx*4+1], pal_raw[idx*4+2]
|
||
rgba[i*4], rgba[i*4+1], rgba[i*4+2], rgba[i*4+3] = r, g, b, 255
|
||
|
||
return width, height, bytes(rgba)
|
||
|
||
# ── DXT (compression == 2) ─────────────────────────────────────────────
|
||
if compression == 2:
|
||
if not _PIL_OK:
|
||
return None
|
||
|
||
# Map alphaCompression → DDS fourCC
|
||
# 0=DXT1, 1=DXT3(premul), 7=DXT5, 9=DXT5
|
||
fourcc_map = {0: b'DXT1', 1: b'DXT3', 7: b'DXT5', 9: b'DXT5'}
|
||
fourcc = fourcc_map.get(alpha_compr, b'DXT1')
|
||
|
||
# Build a minimal DDS file in memory
|
||
DDSD_FLAGS = 0x1 | 0x2 | 0x4 | 0x1000 | 0x80000 # caps,h,w,pf,linearsize
|
||
DDSCAPS_TEXTURE = 0x1000
|
||
DDPF_FOURCC = 0x4
|
||
|
||
header = struct.pack('<4sI', b'DDS ', 124)
|
||
header += struct.pack('<I', DDSD_FLAGS)
|
||
header += struct.pack('<II', height, width)
|
||
header += struct.pack('<I', mip_sizes[0]) # linearSize
|
||
header += struct.pack('<II', 0, 0) # depth, mipMapCount
|
||
header += b'\x00' * 44 # reserved
|
||
# DDS_PIXELFORMAT
|
||
header += struct.pack('<II', 32, DDPF_FOURCC)
|
||
header += fourcc
|
||
header += struct.pack('<IIIII', 0, 0, 0, 0, 0)
|
||
# Caps
|
||
header += struct.pack('<IIII', DDSCAPS_TEXTURE, 0, 0, 0)
|
||
header += struct.pack('<I', 0)
|
||
|
||
dds_bytes = header + mip_data
|
||
try:
|
||
img = _PIL_Image.open(io.BytesIO(dds_bytes))
|
||
img = img.convert('RGBA')
|
||
return img.width, img.height, img.tobytes()
|
||
except Exception:
|
||
return None
|
||
|
||
# ── Raw RGBA (compression == 3) ────────────────────────────────────────
|
||
if compression == 3:
|
||
n = width * height
|
||
rgba = bytearray(n * 4)
|
||
# Stored as BGRA uint32 each
|
||
for i in range(n):
|
||
b, g, r, a = mip_data[i*4], mip_data[i*4+1], mip_data[i*4+2], mip_data[i*4+3]
|
||
rgba[i*4], rgba[i*4+1], rgba[i*4+2], rgba[i*4+3] = r, g, b, a
|
||
return width, height, bytes(rgba)
|
||
|
||
return None
|
||
|
||
|
||
def _rgba_to_png_bytes(width: int, height: int, rgba: bytes) -> bytes:
|
||
"""Encode raw RGBA bytes to PNG using PIL."""
|
||
img = _PIL_Image.frombytes('RGBA', (width, height), rgba)
|
||
buf = io.BytesIO()
|
||
img.save(buf, format='PNG', optimize=False)
|
||
return buf.getvalue()
|
||
|
||
|
||
def _find_blp(m2_dir: Path, name: str) -> Path | None:
|
||
"""Case-insensitive BLP lookup in m2_dir."""
|
||
name_lower = name.lower()
|
||
for p in m2_dir.iterdir():
|
||
if p.suffix.lower() == '.blp' and p.stem.lower() == Path(name_lower).stem.lower():
|
||
return p
|
||
return None
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# WoW animation sequence ID → name table (WotLK 3.3.5)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
ANIM_NAMES = {
|
||
0: "Stand", 1: "Death", 2: "Spell", 3: "Stop", 4: "Walk", 5: "Run",
|
||
6: "Dead", 7: "Rise", 8: "StandWound", 9: "CombatWound", 10: "CombatCritical",
|
||
11: "ShuffleLeft", 12: "ShuffleRight", 13: "WalkBackwards", 14: "Stun",
|
||
15: "HandsClosed", 16: "AttackUnarmed", 17: "Attack1H", 18: "Attack2H",
|
||
19: "Attack2HL", 20: "ParryUnarmed", 21: "Parry1H", 22: "Parry2H",
|
||
23: "Parry2HL", 24: "ShieldBlock", 25: "ReadyUnarmed", 26: "Ready1H",
|
||
27: "Ready2H", 28: "Ready2HL", 29: "ReadyBow", 30: "Dodge",
|
||
31: "SpellPrecast", 32: "SpellCast", 33: "SpellCastArea", 34: "NPCWelcome",
|
||
35: "NPCGoodbye", 36: "Block", 37: "JumpStart", 38: "Jump", 39: "JumpEnd",
|
||
40: "Fall", 41: "SwimIdle", 42: "Swim", 43: "SwimLeft", 44: "SwimRight",
|
||
45: "SwimBackwards", 46: "AttackBow", 47: "FireBow", 48: "ReadyRifle",
|
||
49: "AttackRifle", 50: "Loot", 51: "ReadySpellDirected", 52: "ReadySpellOmni",
|
||
53: "SpellCastDirected", 54: "SpellCastOmni", 55: "BattleRoar",
|
||
56: "ReadyAbility", 57: "Special1H", 58: "Special2H", 59: "ShieldBash",
|
||
60: "EmoteCheer", 61: "EmoteTalk", 62: "EmoteAnswer", 63: "EmoteBeg",
|
||
64: "EmoteEat", 65: "Mount", 66: "EmoteMount", 67: "EmoteAttackUnarmed",
|
||
68: "EmoteAttack1H", 69: "EmoteAttack2H", 70: "EmoteAttack2HL",
|
||
71: "EmoteParryUnarmed", 72: "EmoteParry1H", 73: "EmoteParry2H",
|
||
74: "EmoteParry2HL", 75: "EmoteShieldBlock", 76: "SpellCastOmniUp",
|
||
77: "InteractCrouch", 78: "Death2", 79: "AscentLoop", 80: "DescentLoop",
|
||
81: "Hover", 82: "FlyFall", 83: "FlySwimIdle", 84: "FlySwim",
|
||
85: "FlySwimLeft", 86: "FlySwimRight", 87: "FlySwimBackwards",
|
||
88: "AttackBow_NoPitch", 89: "FireBow_NoPitch",
|
||
}
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Math helpers
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
def quat_mul(a, b):
|
||
ax, ay, az, aw = a
|
||
bx, by, bz, bw = b
|
||
return (
|
||
aw*bx + ax*bw + ay*bz - az*by,
|
||
aw*by - ax*bz + ay*bw + az*bx,
|
||
aw*bz + ax*by - ay*bx + az*bw,
|
||
aw*bw - ax*bx - ay*by - az*bz,
|
||
)
|
||
|
||
def quat_norm(q):
|
||
x, y, z, w = q
|
||
m = math.sqrt(x*x + y*y + z*z + w*w)
|
||
if m < 1e-10:
|
||
return (0.0, 0.0, 0.0, 1.0)
|
||
return (x/m, y/m, z/m, w/m)
|
||
|
||
def quat_conj(q):
|
||
x, y, z, w = q
|
||
return (-x, -y, -z, w)
|
||
|
||
def vec3_transform_quat(v, q):
|
||
"""Rotate vector v by quaternion q."""
|
||
# t = 2 * cross(q.xyz, v)
|
||
qx, qy, qz, qw = q
|
||
vx, vy, vz = v
|
||
tx = 2.0 * (qy*vz - qz*vy)
|
||
ty = 2.0 * (qz*vx - qx*vz)
|
||
tz = 2.0 * (qx*vy - qy*vx)
|
||
return (
|
||
vx + qw*tx + qy*tz - qz*ty,
|
||
vy + qw*ty + qz*tx - qx*tz,
|
||
vz + qw*tz + qx*ty - qy*tx,
|
||
)
|
||
|
||
# WoW M2 model space → Godot (Y-up, right-handed)
|
||
# WoW M2: X=right, Y=forward (into screen), Z=up
|
||
# Godot: X=right, Y=up, Z=backward
|
||
# So: gx=wx, gy=wz, gz=-wy
|
||
def wow_pos_to_gltf(x, y, z):
|
||
return (x, z, -y)
|
||
|
||
def wow_quat_to_gltf(qx, qy, qz, qw):
|
||
# Apply same basis change to quaternion
|
||
# New basis: (1,0,0), (0,0,-1), (0,1,0) in old basis
|
||
# This corresponds to rotating by 90° around X then adjusting
|
||
# Effectively: new_q = basis_q * old_q * basis_q_conj
|
||
# where basis_q rotates (0,1,0) → (0,0,1): that's 90° around X = (sin45, 0, 0, cos45)
|
||
# Simpler: just remap components
|
||
# If v' = (vx, vz, -vy) = M*v, and q rotates v, then q' = M * q * M^-1
|
||
# M = [[1,0,0],[0,0,1],[0,-1,0]]
|
||
# New quat: x'=qx, y'=qz, z'=-qy, w'=qw
|
||
return (qx, qz, -qy, qw)
|
||
|
||
def wow_scale_to_gltf(sx, sy, sz):
|
||
# Scale is symmetric, just remap axes
|
||
return (sx, sz, sy)
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# M2 binary parser
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
class M2Parser:
|
||
def __init__(self, path):
|
||
self.path = Path(path)
|
||
self.dir = self.path.parent
|
||
self.stem = self.path.stem
|
||
|
||
with open(path, 'rb') as f:
|
||
self.data = f.read()
|
||
|
||
# Load per-animation external .anim buffers: anim_index → bytes
|
||
self.anim_buffers = {}
|
||
|
||
self._parse()
|
||
|
||
# ── low-level helpers ────────────────────────────────────────────────────
|
||
|
||
def _u(self, fmt, off):
|
||
return struct.unpack_from(fmt, self.data, off)
|
||
|
||
def _read_array(self, off):
|
||
"""Read M2Array {count, offset} at file position off. Returns (count, data_ofs, next_off)."""
|
||
count, data_ofs = struct.unpack_from('<II', self.data, off)
|
||
return count, data_ofs, off + 8
|
||
|
||
def _read_track(self, off, buf_for_anim=None):
|
||
"""
|
||
Parse M2Track header starting at `off`.
|
||
Returns (track_dict, next_off) where track_dict has:
|
||
interp, global_seq, timestamps[anim_idx], values[anim_idx]
|
||
timestamps/values are lazy – only header parsed here.
|
||
"""
|
||
interp, global_seq = struct.unpack_from('<hh', self.data, off); off += 4
|
||
n_ts, ofs_ts, off = self._read_array(off)
|
||
n_vs, ofs_vs, off = self._read_array(off)
|
||
return {
|
||
'interp': interp,
|
||
'global_seq': global_seq,
|
||
'_n_ts': n_ts, '_ofs_ts': ofs_ts,
|
||
'_n_vs': n_vs, '_ofs_vs': ofs_vs,
|
||
}, off
|
||
|
||
def _resolve_track(self, track, anim_idx, value_fmt):
|
||
"""
|
||
Read actual timestamps+values for animation index anim_idx from this track.
|
||
value_fmt: 'vec3' | 'quat_c' (compressed int16×4) | 'float'
|
||
Returns (timestamps: list[int], values: list[tuple])
|
||
"""
|
||
n_ts = track['_n_ts']
|
||
ofs_ts = track['_ofs_ts']
|
||
n_vs = track['_n_vs']
|
||
ofs_vs = track['_ofs_vs']
|
||
|
||
if anim_idx >= n_ts or anim_idx >= n_vs:
|
||
return [], []
|
||
|
||
# Get the inner M2Array for this animation index
|
||
# Timestamps outer array: array of M2Array<uint32>
|
||
inner_ts_n, inner_ts_ofs = struct.unpack_from('<II', self.data, ofs_ts + anim_idx * 8)
|
||
inner_vs_n, inner_vs_ofs = struct.unpack_from('<II', self.data, ofs_vs + anim_idx * 8)
|
||
|
||
if inner_ts_n == 0 or inner_vs_n == 0:
|
||
return [], []
|
||
|
||
# Choose buffer: external .anim file or M2 file
|
||
buf = self.anim_buffers.get(anim_idx, self.data)
|
||
|
||
# Safety: offset 0 in external buffer means no data
|
||
if inner_ts_ofs == 0 and buf is not self.data:
|
||
return [], []
|
||
|
||
try:
|
||
timestamps = list(struct.unpack_from(f'<{inner_ts_n}I', buf, inner_ts_ofs))
|
||
except struct.error:
|
||
return [], []
|
||
|
||
values = []
|
||
try:
|
||
if value_fmt == 'vec3':
|
||
for j in range(inner_vs_n):
|
||
x, y, z = struct.unpack_from('<3f', buf, inner_vs_ofs + j * 12)
|
||
values.append((x, y, z))
|
||
elif value_fmt == 'quat_c':
|
||
# WoW M2 CompQuat: 4 × uint16 (unsigned), biased by 0x8000.
|
||
# Mapping: 0x0000 → -1.0, 0x8000 → 0.0, 0xFFFF → +1.0
|
||
# Formula: (uint16 - 0x8000) / 0x7FFF
|
||
# NOTE: must read as '<4H' (unsigned), NOT '<4h' (signed int16).
|
||
for j in range(inner_vs_n):
|
||
x, y, z, w = struct.unpack_from('<4H', buf, inner_vs_ofs + j * 8)
|
||
values.append((
|
||
(x - 0x8000) / 32767.0,
|
||
(y - 0x8000) / 32767.0,
|
||
(z - 0x8000) / 32767.0,
|
||
(w - 0x8000) / 32767.0,
|
||
))
|
||
elif value_fmt == 'float':
|
||
for j in range(inner_vs_n):
|
||
v, = struct.unpack_from('<f', buf, inner_vs_ofs + j * 4)
|
||
values.append((v,))
|
||
except struct.error:
|
||
pass
|
||
|
||
return timestamps, values
|
||
|
||
# ── main parse ───────────────────────────────────────────────────────────
|
||
|
||
def _parse(self):
|
||
d = self.data
|
||
|
||
# Magic + version
|
||
magic = d[0:4]
|
||
if magic != b'MD20':
|
||
raise ValueError(f"Not an M2 file (magic={magic!r})")
|
||
version, = struct.unpack_from('<I', d, 4)
|
||
if version != 264:
|
||
raise ValueError(f"Unsupported M2 version {version} (expected 264 for WotLK)")
|
||
|
||
off = 8
|
||
|
||
# ── Header fields ─────────────────────────────────────────────────
|
||
|
||
# name
|
||
n_name, ofs_name, off = self._read_array(off)
|
||
|
||
# globalFlags
|
||
self.global_flags, = struct.unpack_from('<I', d, off); off += 4
|
||
|
||
# globalSequences
|
||
self.n_gseq, self.ofs_gseq, off = self._read_array(off)
|
||
|
||
# animations
|
||
self.n_anim, self.ofs_anim, off = self._read_array(off)
|
||
|
||
# animationLookup
|
||
self.n_alook, self.ofs_alook, off = self._read_array(off)
|
||
|
||
# bones
|
||
self.n_bones, self.ofs_bones, off = self._read_array(off)
|
||
|
||
# keyBoneLookup
|
||
n_kbl, ofs_kbl, off = self._read_array(off)
|
||
|
||
# vertices
|
||
self.n_verts, self.ofs_verts, off = self._read_array(off)
|
||
|
||
# numSkinProfiles (uint32, NOT M2Array)
|
||
self.n_skins, = struct.unpack_from('<I', d, off); off += 4
|
||
|
||
# colors(8) textures(8) textureWeights(8) textureTransforms(8)
|
||
# replaceableTextureLookup(8) materials(8) boneLookupTable(8)
|
||
# textureLookupTable(8) texUnitLookupTable(8) transparencyLookupTable(8)
|
||
# textureTransformLookupTable(8) → 11 × 8 = 88 bytes total
|
||
self.n_tex, self.ofs_tex, _ = self._read_array(off + 8) # textures
|
||
self.n_mat, self.ofs_mat, _ = self._read_array(off + 5*8) # materials
|
||
self.n_tex_look, self.ofs_tex_look, _ = self._read_array(off + 7*8) # textureLookupTable
|
||
off += 11 * 8
|
||
|
||
# boundingBox (CAaBox = 2 × Vec3 = 24), boundingSphereRadius (float)
|
||
off += 24 + 4
|
||
# collisionBox (24), collisionSphereRadius (4)
|
||
off += 24 + 4
|
||
|
||
# collision triangles/vertices/normals (3 × 8)
|
||
off += 3 * 8
|
||
|
||
# attachments, attachmentLookup, events, lights, cameras,
|
||
# cameraLookup, ribbonEmitters, particleEmitters (8 × 8)
|
||
off += 8 * 8
|
||
|
||
# ── Load global sequences ─────────────────────────────────────────
|
||
self.global_sequences = list(
|
||
struct.unpack_from(f'<{self.n_gseq}I', d, self.ofs_gseq)
|
||
) if self.n_gseq > 0 else []
|
||
|
||
# ── Load animation sequences ──────────────────────────────────────
|
||
# M2Sequence: id(2) variationIndex(2) duration(4) movespeed(4)
|
||
# flags(4) frequency(2) _pad(2) replay(8) blendtime(4)
|
||
# bounds(24) radius(4) variationNext(2) aliasNext(2)
|
||
# Total = 64 bytes
|
||
SEQ_SIZE = 64
|
||
self.sequences = []
|
||
for i in range(self.n_anim):
|
||
base = self.ofs_anim + i * SEQ_SIZE
|
||
seq_id, var_idx, duration, movespeed, flags, frequency = \
|
||
struct.unpack_from('<HHIfIh', d, base)
|
||
# aliasNext at offset 62 of the sequence
|
||
alias_next, = struct.unpack_from('<H', d, base + 62)
|
||
self.sequences.append({
|
||
'id': seq_id,
|
||
'variation': var_idx,
|
||
'duration': duration, # ms
|
||
'flags': flags,
|
||
'alias_next': alias_next,
|
||
'index': i,
|
||
})
|
||
|
||
# ── Load external .anim files ─────────────────────────────────────
|
||
for i, seq in enumerate(self.sequences):
|
||
anim_id = seq['id']
|
||
anim_var = seq['variation']
|
||
anim_file = self.dir / f"{self.stem}{anim_id:04d}-{anim_var:02d}.anim"
|
||
if anim_file.exists():
|
||
with open(anim_file, 'rb') as f:
|
||
self.anim_buffers[i] = f.read()
|
||
# print(f" Loaded external anim {anim_id} var{anim_var}: {anim_file.name}")
|
||
|
||
# ── Load vertices ─────────────────────────────────────────────────
|
||
# M2Vertex: pos(12) boneWeights(4) boneIndices(4) normal(12) texCoords(16) = 48
|
||
VERT_SIZE = 48
|
||
self.vertices = []
|
||
for i in range(self.n_verts):
|
||
base = self.ofs_verts + i * VERT_SIZE
|
||
px, py, pz = struct.unpack_from('<3f', d, base)
|
||
bw = struct.unpack_from('<4B', d, base + 12)
|
||
bi = struct.unpack_from('<4B', d, base + 16)
|
||
nx, ny, nz = struct.unpack_from('<3f', d, base + 20)
|
||
u0, v0, u1, v1 = struct.unpack_from('<4f', d, base + 32)
|
||
self.vertices.append({
|
||
'pos': (px, py, pz),
|
||
'bone_weights': bw,
|
||
'bone_indices': bi,
|
||
'normal': (nx, ny, nz),
|
||
'uv0': (u0, v0),
|
||
})
|
||
|
||
# ── Load bones ────────────────────────────────────────────────────
|
||
# M2CompBone (WotLK): keyBoneId(4) flags(4) parentBoneId(2) submeshId(2)
|
||
# bone_name_crc(4) ← present in all WotLK builds
|
||
# translation: M2Track<Vec3> = 20 bytes
|
||
# rotation: M2Track<CompQuat>= 20 bytes
|
||
# scale: M2Track<Vec3> = 20 bytes
|
||
# pivot: Vec3 = 12 bytes
|
||
# Total = 4+4+2+2+4 + 20+20+20 + 12 = 88 bytes
|
||
BONE_SIZE = 88
|
||
self.bones = []
|
||
for i in range(self.n_bones):
|
||
base = self.ofs_bones + i * BONE_SIZE
|
||
key_bone_id, flags = struct.unpack_from('<iI', d, base)
|
||
parent, submesh = struct.unpack_from('<hH', d, base + 8)
|
||
boff = base + 16 # +4 for bone_name_crc
|
||
t_track, boff = self._read_track(boff)
|
||
r_track, boff = self._read_track(boff)
|
||
s_track, boff = self._read_track(boff)
|
||
px, py, pz = struct.unpack_from('<3f', d, boff)
|
||
self.bones.append({
|
||
'key_id': key_bone_id,
|
||
'flags': flags,
|
||
'parent': parent, # -1 = root
|
||
'pivot': (px, py, pz),
|
||
't_track': t_track,
|
||
'r_track': r_track,
|
||
's_track': s_track,
|
||
})
|
||
|
||
# ── Load textures ──────────────────────────────────────────────────
|
||
# M2Texture: type(4) flags(4) filename M2Array(8) = 16 bytes
|
||
TEX_SIZE = 16
|
||
self.textures = []
|
||
for i in range(self.n_tex):
|
||
base = self.ofs_tex + i * TEX_SIZE
|
||
tex_type, tex_flags = struct.unpack_from('<II', d, base)
|
||
n_fn, ofs_fn = struct.unpack_from('<II', d, base + 8)
|
||
filename = ""
|
||
if n_fn > 0 and ofs_fn > 0:
|
||
try:
|
||
raw = d[ofs_fn: ofs_fn + n_fn]
|
||
filename = raw.rstrip(b'\x00').decode('utf-8', errors='replace')
|
||
except Exception:
|
||
pass
|
||
self.textures.append({'type': tex_type, 'flags': tex_flags, 'filename': filename})
|
||
|
||
# ── textureLookupTable: uint16 per entry → index into self.textures ─
|
||
self.texture_lookup = list(
|
||
struct.unpack_from(f'<{self.n_tex_look}H', d, self.ofs_tex_look)
|
||
) if self.n_tex_look > 0 else []
|
||
|
||
# ── Load M2 materials ──────────────────────────────────────────────
|
||
# M2Material: flags(uint16) + blendingMode(uint16) = 4 bytes each
|
||
# blendingMode: 0=Opaque, 1=AlphaKey, 2=Alpha, 3=Add, 4=Mod, 5=Mod2x, 6=ModAdd, 7=InvSrcAlpha...
|
||
self.materials_data = []
|
||
MAT_SIZE = 4
|
||
for i in range(self.n_mat):
|
||
base = self.ofs_mat + i * MAT_SIZE
|
||
mat_flags, blend_mode = struct.unpack_from('<HH', d, base)
|
||
self.materials_data.append({'flags': mat_flags, 'blend_mode': blend_mode})
|
||
|
||
print(f"M2: {self.n_verts} vertices, {self.n_bones} bones, "
|
||
f"{self.n_anim} animations, {len(self.anim_buffers)} external anims, "
|
||
f"{self.n_mat} materials")
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Skin (.skin) parser
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
class SkinParser:
|
||
def __init__(self, path):
|
||
with open(path, 'rb') as f:
|
||
self.data = f.read()
|
||
self._parse()
|
||
|
||
def _parse(self):
|
||
d = self.data
|
||
magic = d[0:4]
|
||
if magic != b'SKIN':
|
||
raise ValueError(f"Not a skin file (magic={magic!r})")
|
||
|
||
off = 4
|
||
def ra(o):
|
||
count, ofs = struct.unpack_from('<II', d, o)
|
||
return count, ofs, o + 8
|
||
|
||
n_v, ofs_v, off = ra(off)
|
||
n_i, ofs_i, off = ra(off)
|
||
n_b, ofs_b, off = ra(off) # bone lookup table: uint16[] maps local→global bone index
|
||
n_s, ofs_s, off = ra(off) # skin sections (submeshes)
|
||
n_bt, ofs_bt, off = ra(off) # render batches
|
||
self.bone_count_max, = struct.unpack_from('<I', d, off)
|
||
|
||
# Local vertex indices (into M2 global vertex list)
|
||
self.local_vertices = list(struct.unpack_from(f'<{n_v}H', d, ofs_v))
|
||
|
||
# Bone lookup table: vertex.bone_indices[j] → bone_lookup[j] → M2 global bone index
|
||
self.bone_lookup = list(struct.unpack_from(f'<{n_b}H', d, ofs_b)) if n_b > 0 else []
|
||
|
||
# Triangle indices (into local_vertices)
|
||
self.indices = list(struct.unpack_from(f'<{n_i}H', d, ofs_i))
|
||
|
||
# Skin sections / submeshes
|
||
# M2SkinSection: skinSectionId(2) Level(2) vertexStart(2) vertexCount(2)
|
||
# indexStart(2) indexCount(2) boneCount(2) startBones(2) boneInfluences(2)
|
||
# centerBoneIndex(2) centerPos(12) sortCenterPos(12) sortRadius(4)
|
||
# Total = 2+2+2+2+2+2+2+2+2+2+12+12+4 = 48 bytes
|
||
SEC_SIZE = 48
|
||
self.sections = []
|
||
for i in range(n_s):
|
||
base = ofs_s + i * SEC_SIZE
|
||
(sec_id, level, vstart, vcount, istart, icount,
|
||
bone_count, start_bones, bone_influences, center_bone) = \
|
||
struct.unpack_from('<10H', d, base)
|
||
self.sections.append({
|
||
'id': sec_id,
|
||
'vertex_start': vstart,
|
||
'vertex_count': vcount,
|
||
'index_start': istart,
|
||
'index_count': icount,
|
||
})
|
||
|
||
# Render batches (texture units)
|
||
# M2Batch (WotLK): flags(1) priorityPlane(1) shader_id(2)
|
||
# skinSectionIndex(2) geosetIndex(2) colorIndex(2) materialIndex(2)
|
||
# materialLayer(2) textureCount(2) textureLookupId(2)
|
||
# textureUnitLookupId(2) transparencyLookupId(2) textureAnimLookupId(2)
|
||
# Total = 1+1+2+2+2+2+2+2+2+2+2+2+2 = 24 bytes
|
||
# M2Batch (WotLK, 24 bytes):
|
||
# flags(1) priorityPlane(1) shader_id(2)
|
||
# skinSectionIndex(2) geosetIndex(2) colorIndex(2) materialIndex(2)
|
||
# materialLayer(2) textureCount(2) textureLookupId(2)
|
||
# textureUnitLookupId(2) transparencyLookupId(2) textureAnimLookupId(2)
|
||
BATCH_SIZE = 24
|
||
# Map skin section index → first texture_lookup_id / materialIndex found for it
|
||
# Batch layout: flags(1) priority(1) shader_id(2) skinSectionIndex(2) geosetIndex(2)
|
||
# colorIndex(2) materialIndex(2) materialLayer(2) textureCount(2)
|
||
# textureLookupId(2) textureUnitLookupId(2) ...
|
||
# offsets: 0 2 4 6
|
||
# 8 10 12 14
|
||
# 16 18 20
|
||
self.section_texture_lookup = {} # sec_idx → tex_look_id
|
||
self.section_material_index = {} # sec_idx → m2 material index
|
||
for i in range(n_bt):
|
||
base = ofs_bt + i * BATCH_SIZE
|
||
(sec_idx,) = struct.unpack_from('<H', d, base + 4)
|
||
(mat_idx,) = struct.unpack_from('<H', d, base + 10) # materialIndex
|
||
(tex_look_id,) = struct.unpack_from('<H', d, base + 16) # textureLookupId
|
||
if sec_idx not in self.section_texture_lookup:
|
||
self.section_texture_lookup[sec_idx] = tex_look_id
|
||
if sec_idx not in self.section_material_index:
|
||
self.section_material_index[sec_idx] = mat_idx
|
||
|
||
print(f"Skin: {n_v} local verts, {n_i//3} triangles, {n_s} sections")
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Minimal GLTF 2.0 / GLB writer
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
class GltfBuilder:
|
||
def __init__(self):
|
||
self.asset = {"version": "2.0", "generator": "WoW M2 to GLTF converter"}
|
||
self.nodes = []
|
||
self.meshes = []
|
||
self.skins = []
|
||
self.accessors = []
|
||
self.buffer_views = []
|
||
self.animations = []
|
||
self.materials = []
|
||
self.images = []
|
||
self.textures = []
|
||
self.bin_data = bytearray()
|
||
|
||
# ── binary buffer helpers ────────────────────────────────────────────────
|
||
|
||
def _add_bin(self, data: bytes, alignment: int = 4) -> int:
|
||
"""Append data to bin buffer (aligned), return byte offset."""
|
||
off = len(self.bin_data)
|
||
pad = (alignment - off % alignment) % alignment
|
||
self.bin_data.extend(b'\x00' * pad)
|
||
off = len(self.bin_data)
|
||
self.bin_data.extend(data)
|
||
return off
|
||
|
||
def _add_accessor(self, data: bytes, count: int, component_type: int,
|
||
acc_type: str, min_vals=None, max_vals=None,
|
||
normalized=False) -> int:
|
||
off = self._add_bin(data)
|
||
bv_idx = len(self.buffer_views)
|
||
self.buffer_views.append({
|
||
"buffer": 0,
|
||
"byteOffset": off,
|
||
"byteLength": len(data),
|
||
})
|
||
acc = {
|
||
"bufferView": bv_idx,
|
||
"byteOffset": 0,
|
||
"componentType": component_type,
|
||
"count": count,
|
||
"type": acc_type,
|
||
}
|
||
if normalized:
|
||
acc["normalized"] = True
|
||
if min_vals is not None:
|
||
acc["min"] = list(min_vals)
|
||
if max_vals is not None:
|
||
acc["max"] = list(max_vals)
|
||
idx = len(self.accessors)
|
||
self.accessors.append(acc)
|
||
return idx
|
||
|
||
# ── build scene ──────────────────────────────────────────────────────────
|
||
|
||
def build(self, m2: M2Parser, skin: SkinParser, anim_filter=None) -> bytes:
|
||
"""
|
||
Build GLB bytes.
|
||
anim_filter: optional set of animation IDs to include (None = all).
|
||
"""
|
||
# 1. Build bone node tree (returns all bone nodes + virtual Armature root)
|
||
bone_node_indices, armature_idx = self._build_skeleton(m2)
|
||
|
||
# 2. Resolve textures → GLTF material indices
|
||
mat_map = self._build_materials(m2, skin)
|
||
|
||
# 3. Build per-geoset mesh nodes
|
||
geoset_node_indices = self._build_mesh(m2, skin, bone_node_indices, mat_map)
|
||
|
||
# 4. Build skin; every geoset node shares the same skin
|
||
skin_idx = self._build_skin(m2, bone_node_indices, armature_idx)
|
||
for ni in geoset_node_indices:
|
||
self.nodes[ni]["skin"] = skin_idx
|
||
|
||
# 5. Build animations
|
||
self._build_animations(m2, bone_node_indices, anim_filter)
|
||
|
||
# 6. Compose scene – single Armature node + geoset mesh nodes
|
||
scene_nodes = [armature_idx] + geoset_node_indices
|
||
|
||
gltf = {
|
||
"asset": self.asset,
|
||
"scene": 0,
|
||
"scenes": [{"nodes": scene_nodes}],
|
||
"nodes": self.nodes,
|
||
"meshes": self.meshes,
|
||
"skins": self.skins,
|
||
"accessors": self.accessors,
|
||
"bufferViews": self.buffer_views,
|
||
"buffers": [{"byteLength": len(self.bin_data)}],
|
||
}
|
||
if self.animations:
|
||
gltf["animations"] = self.animations
|
||
if self.materials:
|
||
gltf["materials"] = self.materials
|
||
if self.textures:
|
||
gltf["textures"] = self.textures
|
||
if self.images:
|
||
gltf["images"] = self.images
|
||
|
||
return self._write_glb(gltf)
|
||
|
||
# ── texture / material builder ────────────────────────────────────────────
|
||
|
||
def _build_materials(self, m2: M2Parser, skin: SkinParser) -> dict:
|
||
"""
|
||
Returns a dict: section_index → gltf_material_index (or None).
|
||
Textures are embedded as PNG in the binary buffer.
|
||
|
||
WoW texture types used for character base appearance:
|
||
0 = hardcoded filename (usually shared parts)
|
||
1 = body skin → {stem}Skin00_00.blp
|
||
2 = object skin
|
||
6 = hair
|
||
8 = fur
|
||
11 = skin extra (naked torso/legs) → {stem}NakedTorsoSkin00_00.blp / NakedPelvisSkin00_00.blp
|
||
"""
|
||
if not _PIL_OK:
|
||
return {}
|
||
|
||
m2_dir = m2.path.parent
|
||
|
||
# ── default skin BLP discovery ──────────────────────────────────────
|
||
# Index BLP files in m2_dir (character-specific textures)
|
||
blp_index = {} # lowercase stem → Path
|
||
for p in m2_dir.iterdir():
|
||
if p.suffix.lower() == '.blp':
|
||
blp_index[p.stem.lower()] = p
|
||
|
||
# Also index race-level directory (one up from m2_dir) for shared
|
||
# textures like hair (e.g. CHARACTER/BloodElf/HAIR00_00.BLP)
|
||
race_blp_index = {}
|
||
race_dir = m2_dir.parent
|
||
if race_dir.is_dir():
|
||
for p in race_dir.iterdir():
|
||
if p.suffix.lower() == '.blp':
|
||
race_blp_index[p.stem.lower()] = p
|
||
|
||
stem_lower = m2.stem.lower()
|
||
|
||
def _default_for_type(tex_type):
|
||
"""Return a Path candidate for a given replaceable texture type."""
|
||
if tex_type == 1:
|
||
# Primary body skin: e.g. BloodElfFemaleSkin00_00.blp
|
||
for k in blp_index:
|
||
if k.startswith(stem_lower + 'skin00_'):
|
||
return blp_index[k]
|
||
elif tex_type == 11:
|
||
# Naked torso or pelvis skin
|
||
for k in blp_index:
|
||
if 'nakedtorsoskin' in k or 'nakedpelvisskin' in k:
|
||
return blp_index[k]
|
||
elif tex_type == 6:
|
||
# Hair: first look in char dir (e.g. BloodElfMaleHair01.blp),
|
||
# then in race dir (e.g. CHARACTER/BloodElf/Hair00_00.BLP)
|
||
for k in blp_index:
|
||
if 'hair' in k or 'scalp' in k:
|
||
return blp_index[k]
|
||
for k in sorted(race_blp_index):
|
||
if 'hair' in k or 'scalp' in k:
|
||
return race_blp_index[k]
|
||
elif tex_type == 2:
|
||
# Object / cape skin – skip, equipment-specific
|
||
return None
|
||
return None
|
||
|
||
# ── cache: blp_path → gltf image index ─────────────────────────────
|
||
blp_cache: dict = {} # key: Path or (str, Path) → gltf texture index
|
||
|
||
def _load_texture(blp_path: Path) -> int | None:
|
||
"""Decode BLP and add to GLTF images/textures, return texture index."""
|
||
if blp_path in blp_cache:
|
||
return blp_cache[blp_path]
|
||
result = _blp_to_rgba(blp_path)
|
||
if result is None:
|
||
blp_cache[blp_path] = None
|
||
return None
|
||
w, h, rgba = result
|
||
png_bytes = _rgba_to_png_bytes(w, h, rgba)
|
||
# Embed PNG in binary buffer
|
||
png_off = self._add_bin(png_bytes, alignment=1)
|
||
bv_idx = len(self.buffer_views)
|
||
self.buffer_views.append({
|
||
"buffer": 0,
|
||
"byteOffset": png_off,
|
||
"byteLength": len(png_bytes),
|
||
})
|
||
img_idx = len(self.images)
|
||
self.images.append({
|
||
"name": blp_path.stem,
|
||
"mimeType": "image/png",
|
||
"bufferView": bv_idx,
|
||
})
|
||
tex_idx = len(self.textures)
|
||
self.textures.append({"source": img_idx})
|
||
blp_cache[blp_path] = tex_idx
|
||
return tex_idx
|
||
|
||
def _load_skin_composited(blp_path: Path) -> int | None:
|
||
"""
|
||
Load body skin BLP and composite all overlay textures for the naked state.
|
||
|
||
WoW character skin UV layout (512x512):
|
||
Upper-left panel (X:0-256, Y:0-128) NakedTorsoSkin – bra area
|
||
Upper-left panel (X:0-256, Y:128-256) NakedPelvisSkin – panties area
|
||
Lower-left quadrant (X:0-256, Y:256-512) = head/face:
|
||
FaceUpper (256x64) at (0, 320) – forehead, upper face
|
||
FaceLower (256x128) at (0, 384) – eyes, mouth, nose
|
||
|
||
All layers are alpha-composited over the base skin in order.
|
||
"""
|
||
cache_key = ("skin_composited", blp_path)
|
||
if cache_key in blp_cache:
|
||
return blp_cache[cache_key]
|
||
|
||
skin_result = _blp_to_rgba(blp_path)
|
||
if skin_result is None:
|
||
blp_cache[cache_key] = None
|
||
return None
|
||
|
||
sw, sh, skin_rgba = skin_result
|
||
skin_img = _PIL_Image.frombytes('RGBA', (sw, sh), skin_rgba)
|
||
|
||
# ── 1. Naked body overlays (torso + pelvis = underwear) ──────────
|
||
# NakedTorsoSkin (256x128) at (0, 0) – upper-left, bra region
|
||
# NakedPelvisSkin (256x128) at (0, 128) – below torso, panties region
|
||
for key_frag, paste_x, paste_y in [
|
||
('nakedtorsoskin', 0, 0),
|
||
('nakedpelvisskin', 0, 128),
|
||
]:
|
||
naked_path = None
|
||
for k, p in blp_index.items():
|
||
if key_frag in k:
|
||
naked_path = p
|
||
break
|
||
if naked_path is None:
|
||
continue
|
||
naked_result = _blp_to_rgba(naked_path)
|
||
if naked_result is None:
|
||
continue
|
||
nw, nh, naked_rgba = naked_result
|
||
naked_img = _PIL_Image.frombytes('RGBA', (nw, nh), naked_rgba)
|
||
skin_img.alpha_composite(naked_img, (paste_x, paste_y))
|
||
print(f" Composited {naked_path.name} onto skin at ({paste_x}, {paste_y})")
|
||
|
||
# ── 2. Face overlays ─────────────────────────────────────────────
|
||
# FaceLower (256x128) at (0, 384) – eyes, mouth, nose
|
||
# FaceUpper (256x64) at (0, 320) – forehead/upper face
|
||
face_lower_y = sh # updated after FaceLower is measured
|
||
|
||
for face_key_fragment in ('facelower', 'faceupper'):
|
||
face_path = None
|
||
for k, p in blp_index.items():
|
||
if face_key_fragment in k:
|
||
face_path = p
|
||
break
|
||
if face_path is None:
|
||
continue
|
||
face_result = _blp_to_rgba(face_path)
|
||
if face_result is None:
|
||
continue
|
||
fw, fh, face_rgba = face_result
|
||
face_img = _PIL_Image.frombytes('RGBA', (fw, fh), face_rgba)
|
||
paste_x = 0
|
||
if face_key_fragment == 'facelower':
|
||
paste_y = sh - fh
|
||
face_lower_y = paste_y
|
||
else:
|
||
paste_y = face_lower_y - fh
|
||
skin_img.alpha_composite(face_img, (paste_x, paste_y))
|
||
print(f" Composited {face_path.name} onto skin at ({paste_x}, {paste_y})")
|
||
|
||
buf = io.BytesIO()
|
||
skin_img.save(buf, format='PNG', optimize=False)
|
||
png_bytes = buf.getvalue()
|
||
# Embed PNG in binary buffer
|
||
png_off = self._add_bin(png_bytes, alignment=1)
|
||
bv_idx = len(self.buffer_views)
|
||
self.buffer_views.append({
|
||
"buffer": 0,
|
||
"byteOffset": png_off,
|
||
"byteLength": len(png_bytes),
|
||
})
|
||
img_idx = len(self.images)
|
||
self.images.append({
|
||
"name": blp_path.stem,
|
||
"mimeType": "image/png",
|
||
"bufferView": bv_idx,
|
||
})
|
||
tex_idx = len(self.textures)
|
||
self.textures.append({"source": img_idx})
|
||
blp_cache[cache_key] = tex_idx
|
||
return tex_idx
|
||
|
||
# ── resolve texture for each M2 texture entry ───────────────────────
|
||
# tex_idx → gltf texture idx
|
||
m2tex_to_gltf: dict[int, int] = {}
|
||
for i, tex in enumerate(m2.textures):
|
||
blp_path = None
|
||
if tex['type'] == 0 and tex['filename']:
|
||
# Hardcoded path: extract filename stem, search in m2_dir first,
|
||
# then sibling directories (WoW sometimes points cross-gender).
|
||
fn_stem = Path(tex['filename'].replace('\\', '/')).stem.lower()
|
||
blp_path = blp_index.get(fn_stem) or race_blp_index.get(fn_stem)
|
||
if blp_path is None:
|
||
# Search sibling dirs (e.g. Female M2 refs Male eye glow)
|
||
for sibling in m2_dir.parent.iterdir():
|
||
if sibling.is_dir() and sibling != m2_dir:
|
||
for p in sibling.iterdir():
|
||
if p.suffix.lower() == '.blp' and p.stem.lower() == fn_stem:
|
||
blp_path = p
|
||
break
|
||
if blp_path:
|
||
break
|
||
else:
|
||
blp_path = _default_for_type(tex['type'])
|
||
if blp_path:
|
||
# Body skin (type=1) gets face overlay composited on top
|
||
if tex['type'] == 1:
|
||
gtex = _load_skin_composited(blp_path)
|
||
else:
|
||
gtex = _load_texture(blp_path)
|
||
if gtex is not None:
|
||
m2tex_to_gltf[i] = gtex
|
||
|
||
# ── map each skin section → gltf material idx ──────────────────────
|
||
# Key: (gltf_tex_idx, m2_blend_mode) → mat_idx so blend_mode=1 (AlphaKey)
|
||
# gets a MASK material and blend_mode=2 gets a BLEND material.
|
||
mat_cache: dict[tuple, int] = {}
|
||
|
||
def _get_or_create_mat(gtex_idx: int, blend_mode: int = 2) -> int:
|
||
key = (gtex_idx, blend_mode)
|
||
if key in mat_cache:
|
||
return mat_cache[key]
|
||
# Map M2 blend mode → GLTF alphaMode
|
||
if blend_mode == 0: # Opaque
|
||
alpha_mode = "OPAQUE"
|
||
elif blend_mode == 1: # AlphaKey (cutout)
|
||
alpha_mode = "MASK"
|
||
else: # Alpha, Add, Mod, etc.
|
||
alpha_mode = "BLEND"
|
||
mat = {
|
||
"pbrMetallicRoughness": {
|
||
"baseColorTexture": {"index": gtex_idx},
|
||
"metallicFactor": 0.0,
|
||
"roughnessFactor": 1.0,
|
||
},
|
||
"alphaMode": alpha_mode,
|
||
"doubleSided": True,
|
||
}
|
||
if blend_mode == 1:
|
||
mat["alphaCutoff"] = 0.5
|
||
mat_idx = len(self.materials)
|
||
self.materials.append(mat)
|
||
mat_cache[key] = mat_idx
|
||
return mat_idx
|
||
|
||
mat_map: dict[int, int] = {} # section_idx → gltf_mat_idx
|
||
for sec_idx, tex_look_id in skin.section_texture_lookup.items():
|
||
# Determine this section's M2 blend mode from its material
|
||
m2mat_idx = skin.section_material_index.get(sec_idx)
|
||
blend_mode = 2 # default: alpha blend
|
||
if m2mat_idx is not None and m2mat_idx < len(m2.materials_data):
|
||
blend_mode = m2.materials_data[m2mat_idx]['blend_mode']
|
||
if tex_look_id < len(m2.texture_lookup):
|
||
m2tex_idx = m2.texture_lookup[tex_look_id]
|
||
gtex = m2tex_to_gltf.get(m2tex_idx)
|
||
if gtex is not None:
|
||
mat_map[sec_idx] = _get_or_create_mat(gtex, blend_mode)
|
||
|
||
n_tex = len(self.images)
|
||
n_mat = len(self.materials)
|
||
if n_tex:
|
||
print(f"Textures: {n_tex} embedded, {n_mat} materials")
|
||
return mat_map
|
||
|
||
# ── skeleton ─────────────────────────────────────────────────────────────
|
||
|
||
def _build_skeleton(self, m2: M2Parser):
|
||
bone_node_indices = []
|
||
|
||
for i, bone in enumerate(m2.bones):
|
||
node_idx = len(self.nodes)
|
||
bone_node_indices.append(node_idx)
|
||
|
||
# Pivot in GLTF space (world-space first; parent-relative below)
|
||
px, py, pz = wow_pos_to_gltf(*bone['pivot'])
|
||
|
||
node = {
|
||
"name": f"bone_{i}",
|
||
"translation": [px, py, pz],
|
||
"rotation": [0.0, 0.0, 0.0, 1.0],
|
||
"scale": [1.0, 1.0, 1.0],
|
||
}
|
||
self.nodes.append(node)
|
||
|
||
# Wire up children
|
||
for i, bone in enumerate(m2.bones):
|
||
parent = bone['parent']
|
||
if parent >= 0 and parent < len(m2.bones):
|
||
parent_node = bone_node_indices[parent]
|
||
child_node = bone_node_indices[i]
|
||
self.nodes[parent_node].setdefault("children", []).append(child_node)
|
||
|
||
# Convert world-space pivots to parent-relative translations
|
||
for i, bone in enumerate(m2.bones):
|
||
parent = bone['parent']
|
||
if parent >= 0 and parent < len(m2.bones):
|
||
ppx, ppy, ppz = wow_pos_to_gltf(*m2.bones[parent]['pivot'])
|
||
node = self.nodes[bone_node_indices[i]]
|
||
t = node["translation"]
|
||
node["translation"] = [t[0]-ppx, t[1]-ppy, t[2]-ppz]
|
||
|
||
# GLTF spec: skin.skeleton must be an ancestor of every joint node.
|
||
# WoW models have multiple root bones (main skeleton + attachment/socket
|
||
# bones each with parent=-1). Wrap them all under a single virtual
|
||
# "Armature" node at the origin so the skin has a valid common root.
|
||
root_bone_nodes = [bone_node_indices[i]
|
||
for i, b in enumerate(m2.bones) if b['parent'] < 0]
|
||
armature_idx = len(self.nodes)
|
||
self.nodes.append({
|
||
"name": "Armature",
|
||
"translation": [0.0, 0.0, 0.0],
|
||
"rotation": [0.0, 0.0, 0.0, 1.0],
|
||
"scale": [1.0, 1.0, 1.0],
|
||
"children": root_bone_nodes,
|
||
})
|
||
|
||
n_roots = len(root_bone_nodes)
|
||
if n_roots > 1:
|
||
print(f"Skeleton: {len(m2.bones)} bones, {n_roots} root bones "
|
||
f"-> wrapped under virtual Armature node")
|
||
|
||
return bone_node_indices, armature_idx
|
||
|
||
# ── mesh ─────────────────────────────────────────────────────────────────
|
||
|
||
def _build_section_primitive(self, m2, skin, sec_idx, mat_map):
|
||
"""Build one GLTF primitive dict for skin section sec_idx. Returns None if empty."""
|
||
sec = skin.sections[sec_idx]
|
||
vstart = sec['vertex_start']
|
||
vcount = sec['vertex_count']
|
||
istart = sec['index_start']
|
||
icount = sec['index_count']
|
||
|
||
if vcount == 0 or icount == 0:
|
||
return None
|
||
|
||
local_vert_slice = skin.local_vertices[vstart: vstart + vcount]
|
||
positions = []; normals = []; uvs = []; joints = []; weights = []
|
||
|
||
for local_idx in range(vcount):
|
||
global_vert_idx = local_vert_slice[local_idx]
|
||
if global_vert_idx >= len(m2.vertices):
|
||
continue
|
||
v = m2.vertices[global_vert_idx]
|
||
positions.extend(wow_pos_to_gltf(*v['pos']))
|
||
normals.extend(wow_pos_to_gltf(*v['normal']))
|
||
uvs.extend([v['uv0'][0], v['uv0'][1]])
|
||
bw = v['bone_weights']
|
||
bw_sum = sum(bw) or 1
|
||
weights.extend([b / bw_sum for b in bw])
|
||
# In WotLK M2, vertex bone_indices are direct M2 bone array indices.
|
||
joints.extend(list(v['bone_indices']))
|
||
|
||
tri_indices = [skin.indices[k] - vstart for k in range(istart, istart + icount)]
|
||
|
||
if not positions or not tri_indices:
|
||
return None
|
||
|
||
n_verts = len(positions) // 3
|
||
pos_bytes = struct.pack(f'<{len(positions)}f', *positions)
|
||
nrm_bytes = struct.pack(f'<{len(normals)}f', *normals)
|
||
uv_bytes = struct.pack(f'<{len(uvs)}f', *uvs)
|
||
jnt_bytes = struct.pack(f'<{len(joints)}H', *joints)
|
||
wgt_bytes = struct.pack(f'<{len(weights)}f', *weights)
|
||
idx_bytes = struct.pack(f'<{len(tri_indices)}H', *tri_indices)
|
||
|
||
xs = positions[0::3]; ys = positions[1::3]; zs = positions[2::3]
|
||
pos_acc = self._add_accessor(pos_bytes, n_verts, 5126, "VEC3",
|
||
[min(xs), min(ys), min(zs)], [max(xs), max(ys), max(zs)])
|
||
nrm_acc = self._add_accessor(nrm_bytes, n_verts, 5126, "VEC3")
|
||
uv_acc = self._add_accessor(uv_bytes, n_verts, 5126, "VEC2")
|
||
jnt_acc = self._add_accessor(jnt_bytes, n_verts, 5123, "VEC4")
|
||
wgt_acc = self._add_accessor(wgt_bytes, n_verts, 5126, "VEC4")
|
||
idx_acc = self._add_accessor(idx_bytes, len(tri_indices), 5123, "SCALAR")
|
||
|
||
prim = {
|
||
"attributes": {
|
||
"POSITION": pos_acc,
|
||
"NORMAL": nrm_acc,
|
||
"TEXCOORD_0": uv_acc,
|
||
"JOINTS_0": jnt_acc,
|
||
"WEIGHTS_0": wgt_acc,
|
||
},
|
||
"indices": idx_acc,
|
||
"mode": 4,
|
||
}
|
||
mat_idx = mat_map.get(sec_idx)
|
||
if mat_idx is not None:
|
||
prim["material"] = mat_idx
|
||
return prim
|
||
|
||
def _build_mesh(self, m2: M2Parser, skin: SkinParser, bone_node_indices, mat_map: dict = None):
|
||
"""
|
||
Build one GLTF mesh node per unique geoset ID.
|
||
|
||
WoW geoset ID convention:
|
||
0 = base body (always visible)
|
||
1xx = skin body variation (cat=1, var=xx; show only var=01 by default)
|
||
2xx = face lower
|
||
3xx = face upper
|
||
4xx = hair style
|
||
5xx = facial feature (beard, etc.)
|
||
6xx = eyelashes
|
||
7xx-9xx = accessories, tabard, cloak
|
||
1000+ = additional optional parts
|
||
|
||
Each geoset becomes a separate MeshInstance3D in Godot so you can
|
||
show/hide individual parts via GDScript.
|
||
"""
|
||
if mat_map is None:
|
||
mat_map = {}
|
||
|
||
# Correct geoset ID ranges from M2SkinMeshPartID (WotLK 3.3.5)
|
||
# Source: pywowlib/enums/m2_enums.py :: M2SkinMeshPartID
|
||
GEOSET_RANGES = [
|
||
(range(0, 1), "skin"), # base body – always shown
|
||
(range(1, 35), "hair"), # hair styles
|
||
(range(101, 125), "facial1_beard"),
|
||
(range(201, 220), "facial2_mustache"),
|
||
(range(301, 320), "facial3_sideburns"),
|
||
(range(401, 406), "gloves"),
|
||
(range(501, 511), "boots"),
|
||
(range(601, 615), "shirt"),
|
||
(range(701, 712), "ears"),
|
||
(range(801, 805), "wristbands"),
|
||
(range(901, 906), "kneepads"),
|
||
(range(1001, 1005), "chest"),
|
||
(range(1101, 1106), "pants"),
|
||
(range(1201, 1205), "tabard"),
|
||
(range(1301, 1304), "legs"),
|
||
(range(1401, 1415), "shirt_doublet"),
|
||
(range(1501, 1525), "cape"),
|
||
(range(1601, 1615), "facial_jewelry"),
|
||
(range(1701, 1706), "eye_effects"),
|
||
(range(1801, 1805), "belt"),
|
||
(range(1901, 1915), "trail"),
|
||
(range(2001, 2009), "feet"),
|
||
]
|
||
|
||
def _geoset_name(gid: int) -> str:
|
||
for r, name in GEOSET_RANGES:
|
||
if gid in r:
|
||
variant = gid - r.start
|
||
return f"geoset_{gid:04d}_{name}_v{variant:02d}"
|
||
return f"geoset_{gid:04d}_unknown"
|
||
|
||
# Group sections by geoset ID
|
||
geoset_sections: dict[int, list[int]] = {}
|
||
for sec_idx, sec in enumerate(skin.sections):
|
||
gid = sec['id']
|
||
geoset_sections.setdefault(gid, []).append(sec_idx)
|
||
|
||
geoset_node_indices = []
|
||
category_set = set()
|
||
for gid in sorted(geoset_sections):
|
||
primitives = []
|
||
for sec_idx in geoset_sections[gid]:
|
||
prim = self._build_section_primitive(m2, skin, sec_idx, mat_map)
|
||
if prim is not None:
|
||
primitives.append(prim)
|
||
|
||
if not primitives:
|
||
continue
|
||
|
||
node_name = _geoset_name(gid)
|
||
# Extract category name (part between second and third underscore)
|
||
cat_name = node_name.split("_")[2] if node_name.count("_") >= 2 else "unknown"
|
||
category_set.add(cat_name)
|
||
|
||
# Determine M2 blend_mode for this geoset (use first section's material)
|
||
blend_mode = 0
|
||
for sec_idx in geoset_sections[gid]:
|
||
mid = skin.section_material_index.get(sec_idx)
|
||
if mid is not None and mid < len(m2.materials_data):
|
||
blend_mode = m2.materials_data[mid]['blend_mode']
|
||
break
|
||
|
||
mesh_idx = len(self.meshes)
|
||
self.meshes.append({"name": node_name, "primitives": primitives})
|
||
|
||
node_idx = len(self.nodes)
|
||
self.nodes.append({
|
||
"name": node_name,
|
||
"mesh": mesh_idx,
|
||
"extras": {"geoset_id": gid, "blend_mode": blend_mode},
|
||
})
|
||
geoset_node_indices.append(node_idx)
|
||
|
||
print(f"Geosets: {len(geoset_node_indices)} nodes, types: {sorted(category_set)}")
|
||
return geoset_node_indices
|
||
|
||
# ── skin (armature binding) ───────────────────────────────────────────────
|
||
|
||
def _build_skin(self, m2: M2Parser, bone_node_indices, armature_idx: int):
|
||
"""Build inverse bind matrices and GLTF skin."""
|
||
n_bones = len(m2.bones)
|
||
|
||
ibm_floats = []
|
||
for bone in m2.bones:
|
||
# Inverse bind matrix for bone i:
|
||
# At bind pose all rotations are identity, so world transform = T(pivot_world).
|
||
# IBM = T(-pivot_world), column-major (GLTF convention).
|
||
px, py, pz = wow_pos_to_gltf(*bone['pivot'])
|
||
mat = [
|
||
1.0, 0.0, 0.0, 0.0,
|
||
0.0, 1.0, 0.0, 0.0,
|
||
0.0, 0.0, 1.0, 0.0,
|
||
-px, -py, -pz, 1.0,
|
||
]
|
||
ibm_floats.extend(mat)
|
||
|
||
ibm_bytes = struct.pack(f'<{len(ibm_floats)}f', *ibm_floats)
|
||
ibm_acc = self._add_accessor(ibm_bytes, n_bones, 5126, "MAT4")
|
||
|
||
skin_idx = len(self.skins)
|
||
self.skins.append({
|
||
"name": f"{m2.stem}_armature",
|
||
# skeleton = common ancestor of all joints (the virtual Armature node)
|
||
"skeleton": armature_idx,
|
||
"joints": bone_node_indices,
|
||
"inverseBindMatrices": ibm_acc,
|
||
})
|
||
return skin_idx
|
||
|
||
# ── animations ───────────────────────────────────────────────────────────
|
||
|
||
def _build_animations(self, m2: M2Parser, bone_node_indices, anim_filter):
|
||
for seq_idx, seq in enumerate(m2.sequences):
|
||
anim_id = seq['id']
|
||
if anim_filter is not None and anim_id not in anim_filter:
|
||
continue
|
||
|
||
# Skip alias animations (point to another sequence)
|
||
if seq['alias_next'] != seq_idx and seq['alias_next'] != 0xFFFF:
|
||
continue
|
||
|
||
duration_s = seq['duration'] / 1000.0
|
||
if duration_s <= 0:
|
||
continue
|
||
|
||
name = ANIM_NAMES.get(anim_id, f"Anim_{anim_id}")
|
||
if seq['variation'] > 0:
|
||
name += f"_var{seq['variation']}"
|
||
|
||
channels = []
|
||
samplers = []
|
||
|
||
for bone_idx, bone in enumerate(m2.bones):
|
||
node_idx = bone_node_indices[bone_idx]
|
||
|
||
# Translation
|
||
# M2 t_track values are OFFSETS from the bone's rest position (pivot
|
||
# relative to parent pivot). GLTF animation overwrites node.translation
|
||
# entirely, so we must emit: rest_local + m2_offset (both in GLTF space).
|
||
ts, vals = m2._resolve_track(bone['t_track'], seq_idx, 'vec3')
|
||
if ts and vals:
|
||
rest = self.nodes[node_idx].get("translation", [0.0, 0.0, 0.0])
|
||
rx, ry, rz = rest[0], rest[1], rest[2]
|
||
# Use a factory to capture rx/ry/rz by value (avoid late-binding bug)
|
||
def _make_trans_fn(rx, ry, rz):
|
||
def fn(v):
|
||
cx, cy, cz = wow_pos_to_gltf(*v)
|
||
return (rx + cx, ry + cy, rz + cz)
|
||
return fn
|
||
s_idx = self._anim_sampler(ts, vals, 'VEC3', _make_trans_fn(rx, ry, rz))
|
||
if s_idx is not None:
|
||
samplers.append(s_idx)
|
||
channels.append({"sampler": len(samplers)-1,
|
||
"target": {"node": node_idx, "path": "translation"}})
|
||
|
||
# Rotation
|
||
# M2 r_track quaternions are the bone's absolute local rotation.
|
||
# Rest rotation is identity so no adjustment needed.
|
||
ts, vals = m2._resolve_track(bone['r_track'], seq_idx, 'quat_c')
|
||
if ts and vals:
|
||
s_idx = self._anim_sampler(ts, vals, 'VEC4',
|
||
lambda v: wow_quat_to_gltf(*v))
|
||
if s_idx is not None:
|
||
samplers.append(s_idx)
|
||
channels.append({"sampler": len(samplers)-1,
|
||
"target": {"node": node_idx, "path": "rotation"}})
|
||
|
||
# Scale
|
||
# M2 s_track values are absolute local scale (1,1,1 at rest).
|
||
ts, vals = m2._resolve_track(bone['s_track'], seq_idx, 'vec3')
|
||
if ts and vals:
|
||
s_idx = self._anim_sampler(ts, vals, 'VEC3',
|
||
lambda v: wow_scale_to_gltf(*v))
|
||
if s_idx is not None:
|
||
samplers.append(s_idx)
|
||
channels.append({"sampler": len(samplers)-1,
|
||
"target": {"node": node_idx, "path": "scale"}})
|
||
|
||
if channels:
|
||
self.animations.append({
|
||
"name": name,
|
||
"samplers": samplers,
|
||
"channels": channels,
|
||
})
|
||
print(f" Anim '{name}': {len(channels)} channels")
|
||
|
||
def _anim_sampler(self, timestamps_ms, values, acc_type, conv_fn):
|
||
"""
|
||
Build a GLTF animation sampler dict from raw M2 timestamps (ms) and values.
|
||
Returns the sampler dict to append to animation["samplers"], or None on error.
|
||
"""
|
||
if not timestamps_ms or not values or len(timestamps_ms) != len(values):
|
||
return None
|
||
|
||
n = len(timestamps_ms)
|
||
|
||
# Input: timestamps in seconds
|
||
times = [t / 1000.0 for t in timestamps_ms]
|
||
t_bytes = struct.pack(f'<{n}f', *times)
|
||
t_min = [min(times)]
|
||
t_max = [max(times)]
|
||
t_acc = self._add_accessor(t_bytes, n, 5126, "SCALAR", t_min, t_max)
|
||
|
||
# Output: converted values
|
||
try:
|
||
converted = [conv_fn(v) for v in values]
|
||
except Exception:
|
||
converted = [tuple(v) for v in values]
|
||
|
||
flat = [x for c in converted for x in c]
|
||
v_bytes = struct.pack(f'<{len(flat)}f', *flat)
|
||
|
||
if acc_type == 'VEC4':
|
||
# Normalize quaternions
|
||
norm_flat = []
|
||
for i in range(0, len(flat), 4):
|
||
q = quat_norm(flat[i:i+4])
|
||
norm_flat.extend(q)
|
||
v_bytes = struct.pack(f'<{len(norm_flat)}f', *norm_flat)
|
||
|
||
v_acc = self._add_accessor(v_bytes, n, 5126, acc_type)
|
||
|
||
return {"input": t_acc, "output": v_acc, "interpolation": "LINEAR"}
|
||
|
||
# ── GLB writer ───────────────────────────────────────────────────────────
|
||
|
||
def _write_glb(self, gltf: dict) -> bytes:
|
||
json_bytes = json.dumps(gltf, separators=(',', ':')).encode('utf-8')
|
||
# Pad JSON to 4-byte boundary with spaces
|
||
pad = (4 - len(json_bytes) % 4) % 4
|
||
json_bytes += b' ' * pad
|
||
|
||
bin_bytes = bytes(self.bin_data)
|
||
# Pad binary to 4-byte boundary
|
||
bin_pad = (4 - len(bin_bytes) % 4) % 4
|
||
bin_bytes += b'\x00' * bin_pad
|
||
|
||
# GLB header: magic(4) version(4) total_length(4)
|
||
json_chunk = struct.pack('<II', len(json_bytes), 0x4E4F534A) + json_bytes
|
||
bin_chunk = struct.pack('<II', len(bin_bytes), 0x004E4942) + bin_bytes
|
||
|
||
total = 12 + len(json_chunk) + len(bin_chunk)
|
||
header = struct.pack('<III', 0x46546C67, 2, total)
|
||
|
||
return header + json_chunk + bin_chunk
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Entry point
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
def _export_texture_layers(m2_path: Path, out_dir: Path) -> Path | None:
|
||
"""
|
||
Export individual BLP texture layers as PNG files for runtime compositing.
|
||
|
||
Scans the M2's directory for the following layer types and saves them to
|
||
{out_dir}/{stem}_textures/ with a normalised naming scheme:
|
||
|
||
skin_{color:02d}.png – base body skin variants
|
||
naked_torso_{color:02d}.png – NakedTorsoSkin (bra area)
|
||
naked_pelvis_{color:02d}.png – NakedPelvisSkin (panties area)
|
||
face_lower_{style:02d}_{color:02d}.png
|
||
face_upper_{style:02d}_{color:02d}.png
|
||
|
||
Returns the textures directory Path, or None if PIL is not available.
|
||
"""
|
||
if not _PIL_OK:
|
||
return None
|
||
|
||
import re as _re
|
||
|
||
m2_dir = m2_path.parent
|
||
stem = m2_path.stem
|
||
tex_dir = out_dir / f"{stem}_textures"
|
||
tex_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
stem_esc = _re.escape(stem.lower())
|
||
# (regex_pattern, layer_name, has_style_group)
|
||
layer_rules = [
|
||
(_re.compile(rf'^{stem_esc}skin(\d+)_(\d+)$', _re.I), 'skin', False),
|
||
(_re.compile(rf'^{stem_esc}nakedtorsoskin(\d+)_(\d+)$', _re.I), 'naked_torso', False),
|
||
(_re.compile(rf'^{stem_esc}nakedpelvisskin(\d+)_(\d+)$', _re.I), 'naked_pelvis', False),
|
||
(_re.compile(rf'^{stem_esc}facelower(\d+)_(\d+)$', _re.I), 'face_lower', True),
|
||
(_re.compile(rf'^{stem_esc}faceupper(\d+)_(\d+)$', _re.I), 'face_upper', True),
|
||
]
|
||
|
||
count = 0
|
||
for blp_file in sorted(m2_dir.iterdir()):
|
||
if blp_file.suffix.lower() != '.blp':
|
||
continue
|
||
blp_stem = blp_file.stem
|
||
|
||
for pattern, layer_name, has_style in layer_rules:
|
||
m = pattern.match(blp_stem)
|
||
if not m:
|
||
continue
|
||
style_idx = int(m.group(1))
|
||
color_idx = int(m.group(2))
|
||
|
||
if has_style:
|
||
out_name = f"{layer_name}_{style_idx:02d}_{color_idx:02d}.png"
|
||
else:
|
||
out_name = f"{layer_name}_{color_idx:02d}.png"
|
||
|
||
result = _blp_to_rgba(blp_file)
|
||
if result is None:
|
||
break
|
||
w, h, rgba = result
|
||
png_bytes = _rgba_to_png_bytes(w, h, rgba)
|
||
(tex_dir / out_name).write_bytes(png_bytes)
|
||
count += 1
|
||
break # matched – next BLP file
|
||
|
||
if count:
|
||
print(f"Texture layers: {count} PNGs -> {tex_dir.name}/")
|
||
return tex_dir
|
||
|
||
|
||
def convert(m2_path: str, output_dir: str = None, anim_filter=None):
|
||
m2_path = Path(m2_path)
|
||
if not m2_path.exists():
|
||
raise FileNotFoundError(f"M2 file not found: {m2_path}")
|
||
|
||
print(f"\n=== Converting {m2_path.name} ===")
|
||
|
||
# Find skin file: <stem>00.skin
|
||
skin_path = m2_path.parent / (m2_path.stem + "00.skin")
|
||
if not skin_path.exists():
|
||
# Try uppercase
|
||
for f in m2_path.parent.iterdir():
|
||
if f.suffix.lower() == '.skin':
|
||
skin_path = f
|
||
break
|
||
if not skin_path.exists():
|
||
raise FileNotFoundError(f"Skin file not found near {m2_path}")
|
||
print(f"Skin: {skin_path.name}")
|
||
|
||
m2 = M2Parser(str(m2_path))
|
||
skin = SkinParser(str(skin_path))
|
||
|
||
builder = GltfBuilder()
|
||
glb = builder.build(m2, skin, anim_filter=anim_filter)
|
||
|
||
out_dir = Path(output_dir) if output_dir else m2_path.parent
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
out_path = out_dir / (m2_path.stem + ".glb")
|
||
|
||
with open(out_path, 'wb') as f:
|
||
f.write(glb)
|
||
|
||
print(f"Wrote {len(glb):,} bytes -> {out_path}")
|
||
|
||
# Export individual texture layers for runtime compositing
|
||
_export_texture_layers(m2_path, out_dir)
|
||
|
||
return str(out_path)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
import argparse
|
||
|
||
ap = argparse.ArgumentParser(
|
||
description="WoW 3.3.5 M2 -> GLTF 2.0 GLB converter",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
)
|
||
ap.add_argument("input", nargs="?",
|
||
help="Path to a single .M2 file (omit when using --all)")
|
||
ap.add_argument("output_dir", nargs="?",
|
||
help="Output directory for single-file mode")
|
||
ap.add_argument("--all", metavar="SEARCH_DIR",
|
||
help="Recursively convert all .M2 files under SEARCH_DIR")
|
||
ap.add_argument("--out", metavar="OUT_ROOT", default=None,
|
||
help="Root output dir for --all mode. "
|
||
"Mirror the subpath relative to SEARCH_DIR. "
|
||
"Defaults to <cwd>/resources")
|
||
args = ap.parse_args()
|
||
|
||
if args.all:
|
||
search_root = Path(args.all).resolve()
|
||
out_root = Path(args.out).resolve() if args.out else Path("resources")
|
||
m2_files = sorted(search_root.rglob("*.M2"))
|
||
if not m2_files:
|
||
print(f"No .M2 files found under {search_root}")
|
||
sys.exit(1)
|
||
print(f"Found {len(m2_files)} M2 file(s) under {search_root}")
|
||
ok = err = 0
|
||
for m2 in m2_files:
|
||
rel = m2.relative_to(search_root)
|
||
out_dir = out_root / rel.parent
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
try:
|
||
convert(str(m2), str(out_dir))
|
||
ok += 1
|
||
except Exception as e:
|
||
print(f" ERROR {m2.name}: {e}")
|
||
err += 1
|
||
print(f"\nDone: {ok} converted, {err} failed")
|
||
elif args.input:
|
||
convert(args.input, args.output_dir)
|
||
else:
|
||
ap.print_help()
|
||
sys.exit(1)
|