mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-03-22 23:30:14 +00:00
Add M2/WMO 3D viewer with textured rendering, animation, and audio playback
- New tools/m2_viewer.py: Pygame/OpenGL viewer for M2 models (textured rendering, skeletal animation, orbit camera) and WMO buildings - M2 viewer: per-batch texture mapping, CPU vertex skinning, animation playback with play/pause/speed controls, wireframe overlay toggle - WMO viewer: root+group file parsing (MOTX/MOMT/MOVT/MOVI/MONR/MOTV/MOBA), per-batch material rendering with BLP textures - Asset browser: "Open 3D Viewer" buttons for M2 and WMO previews, audio Play/Stop buttons using pygame.mixer in subprocess - Handles both WotLK (v264) and Vanilla (v256) M2 formats
This commit is contained in:
parent
edf0a40759
commit
55faacef96
2 changed files with 2579 additions and 13 deletions
|
|
@ -41,6 +41,20 @@ PIPELINE_DIR = ROOT_DIR / "asset_pipeline"
|
||||||
STATE_FILE = PIPELINE_DIR / "state.json"
|
STATE_FILE = PIPELINE_DIR / "state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _audio_subprocess(file_path: str) -> None:
|
||||||
|
"""Play an audio file using pygame.mixer in a subprocess."""
|
||||||
|
try:
|
||||||
|
import pygame
|
||||||
|
pygame.mixer.init(frequency=44100, size=-16, channels=2, buffer=2048)
|
||||||
|
pygame.mixer.music.load(file_path)
|
||||||
|
pygame.mixer.music.play()
|
||||||
|
while pygame.mixer.music.get_busy():
|
||||||
|
pygame.time.wait(100)
|
||||||
|
pygame.mixer.quit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PackInfo:
|
class PackInfo:
|
||||||
pack_id: str
|
pack_id: str
|
||||||
|
|
@ -487,6 +501,7 @@ class AssetPipelineGUI:
|
||||||
|
|
||||||
def _build_browser_tab(self) -> None:
|
def _build_browser_tab(self) -> None:
|
||||||
self._browser_manifest: dict[str, dict] = {}
|
self._browser_manifest: dict[str, dict] = {}
|
||||||
|
self._browser_manifest_lc: dict[str, str] = {}
|
||||||
self._browser_manifest_list: list[str] = []
|
self._browser_manifest_list: list[str] = []
|
||||||
self._browser_tree_populated: set[str] = set()
|
self._browser_tree_populated: set[str] = set()
|
||||||
self._browser_photo: Any = None # prevent GC of PhotoImage
|
self._browser_photo: Any = None # prevent GC of PhotoImage
|
||||||
|
|
@ -587,6 +602,11 @@ class AssetPipelineGUI:
|
||||||
self._browser_manifest_list = sorted(self._browser_manifest.keys(), key=str.lower)
|
self._browser_manifest_list = sorted(self._browser_manifest.keys(), key=str.lower)
|
||||||
self._browser_count_var.set(f"{len(self._browser_manifest)} entries")
|
self._browser_count_var.set(f"{len(self._browser_manifest)} entries")
|
||||||
|
|
||||||
|
# Build case-insensitive lookup: lowercase forward-slash path -> actual manifest path
|
||||||
|
self._browser_manifest_lc: dict[str, str] = {}
|
||||||
|
for p in self._browser_manifest:
|
||||||
|
self._browser_manifest_lc[p.lower()] = p
|
||||||
|
|
||||||
# Build directory tree indices: one full, one filtered
|
# Build directory tree indices: one full, one filtered
|
||||||
# Single O(N) pass so tree operations are O(1) lookups
|
# Single O(N) pass so tree operations are O(1) lookups
|
||||||
_hidden_exts = {".anim", ".skin"}
|
_hidden_exts = {".anim", ".skin"}
|
||||||
|
|
@ -851,7 +871,147 @@ class AssetPipelineGUI:
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
ttk.Label(self._browser_preview_frame, text=f"Image load error: {exc}").pack(expand=True)
|
ttk.Label(self._browser_preview_frame, text=f"Image load error: {exc}").pack(expand=True)
|
||||||
|
|
||||||
# ── M2 Wireframe Preview ──
|
# ── M2 Preview (wireframe + textures + animations) ──
|
||||||
|
|
||||||
|
# Common animation ID names
|
||||||
|
_ANIM_NAMES: dict[int, str] = {
|
||||||
|
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",
|
||||||
|
60: "SpellChannelDirected", 61: "SpellChannelOmni",
|
||||||
|
69: "CombatAbility", 70: "CombatAbility2H",
|
||||||
|
94: "Kneel", 113: "Loot",
|
||||||
|
135: "ReadyRifle", 138: "Fly", 143: "CustomSpell01",
|
||||||
|
157: "EmoteTalk", 185: "FlyIdle",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Texture type names for non-filename textures
|
||||||
|
_TEX_TYPE_NAMES: dict[int, str] = {
|
||||||
|
0: "Filename", 1: "Body/Skin", 2: "Object Skin", 3: "Weapon Blade",
|
||||||
|
4: "Weapon Handle", 5: "Environment", 6: "Hair", 7: "Facial Hair",
|
||||||
|
8: "Skin Extra", 9: "UI Skin", 10: "Tauren Mane", 11: "Monster Skin 1",
|
||||||
|
12: "Monster Skin 2", 13: "Monster Skin 3", 14: "Item Icon",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _browser_parse_m2_textures(self, data: bytes, version: int) -> list[dict]:
|
||||||
|
"""Parse M2 texture definitions. Returns list of {type, flags, filename}."""
|
||||||
|
if version <= 256:
|
||||||
|
ofs = 92
|
||||||
|
else:
|
||||||
|
ofs = 80
|
||||||
|
|
||||||
|
if len(data) < ofs + 8:
|
||||||
|
return []
|
||||||
|
|
||||||
|
n_tex, ofs_tex = struct.unpack_from("<II", data, ofs)
|
||||||
|
if n_tex == 0 or n_tex > 1000 or ofs_tex + n_tex * 16 > len(data):
|
||||||
|
return []
|
||||||
|
|
||||||
|
textures = []
|
||||||
|
for i in range(n_tex):
|
||||||
|
base = ofs_tex + i * 16
|
||||||
|
tex_type, tex_flags = struct.unpack_from("<II", data, base)
|
||||||
|
name_len, name_ofs = struct.unpack_from("<II", data, base + 8)
|
||||||
|
filename = ""
|
||||||
|
if tex_type == 0 and name_len > 1 and name_ofs + name_len <= len(data):
|
||||||
|
raw = data[name_ofs:name_ofs + name_len]
|
||||||
|
filename = raw.split(b"\x00", 1)[0].decode("ascii", errors="replace")
|
||||||
|
textures.append({"type": tex_type, "flags": tex_flags, "filename": filename})
|
||||||
|
return textures
|
||||||
|
|
||||||
|
def _browser_parse_m2_animations(self, data: bytes, version: int) -> list[dict]:
|
||||||
|
"""Parse M2 animation sequences. Returns list of {id, variation, duration, speed, flags}."""
|
||||||
|
if len(data) < 36:
|
||||||
|
return []
|
||||||
|
|
||||||
|
n_anim, ofs_anim = struct.unpack_from("<II", data, 28)
|
||||||
|
if n_anim == 0 or n_anim > 5000:
|
||||||
|
return []
|
||||||
|
|
||||||
|
seq_size = 68 if version <= 256 else 64
|
||||||
|
if ofs_anim + n_anim * seq_size > len(data):
|
||||||
|
return []
|
||||||
|
|
||||||
|
anims = []
|
||||||
|
for i in range(n_anim):
|
||||||
|
base = ofs_anim + i * seq_size
|
||||||
|
anim_id, variation = struct.unpack_from("<HH", data, base)
|
||||||
|
if version <= 256:
|
||||||
|
# Vanilla: startTimestamp(4) + endTimestamp(4), duration = end - start
|
||||||
|
start_ts, end_ts = struct.unpack_from("<II", data, base + 4)
|
||||||
|
duration = end_ts - start_ts
|
||||||
|
speed = struct.unpack_from("<f", data, base + 12)[0]
|
||||||
|
flags = struct.unpack_from("<I", data, base + 16)[0]
|
||||||
|
else:
|
||||||
|
duration = struct.unpack_from("<I", data, base + 4)[0]
|
||||||
|
speed = struct.unpack_from("<f", data, base + 8)[0]
|
||||||
|
flags = struct.unpack_from("<I", data, base + 12)[0]
|
||||||
|
anims.append({
|
||||||
|
"id": anim_id, "variation": variation,
|
||||||
|
"duration": duration, "speed": speed, "flags": flags,
|
||||||
|
})
|
||||||
|
return anims
|
||||||
|
|
||||||
|
def _browser_resolve_blp_path(self, blp_name: str) -> Path | None:
|
||||||
|
"""Resolve a BLP filename from M2 texture to a filesystem path, case-insensitively."""
|
||||||
|
# Normalize: backslash -> forward slash
|
||||||
|
normalized = blp_name.replace("\\", "/")
|
||||||
|
lc = normalized.lower()
|
||||||
|
|
||||||
|
# Try direct manifest lookup
|
||||||
|
actual = self._browser_manifest_lc.get(lc)
|
||||||
|
if actual:
|
||||||
|
return self._browser_resolve_path(actual)
|
||||||
|
|
||||||
|
# Try without leading slash
|
||||||
|
if lc.startswith("/"):
|
||||||
|
actual = self._browser_manifest_lc.get(lc[1:])
|
||||||
|
if actual:
|
||||||
|
return self._browser_resolve_path(actual)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _browser_load_blp_thumbnail(self, blp_path: Path, size: int = 64) -> Any:
|
||||||
|
"""Convert BLP to PNG and return a PhotoImage thumbnail, or None."""
|
||||||
|
if not HAS_PILLOW:
|
||||||
|
return None
|
||||||
|
|
||||||
|
blp_convert = ROOT_DIR / "build" / "bin" / "blp_convert"
|
||||||
|
if not blp_convert.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
cache_dir = PIPELINE_DIR / "preview_cache"
|
||||||
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
cache_key = hashlib.md5(str(blp_path).encode()).hexdigest()
|
||||||
|
cached_png = cache_dir / f"{cache_key}.png"
|
||||||
|
|
||||||
|
if not cached_png.exists():
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[str(blp_convert), "--to-png", str(blp_path)],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
output_png = blp_path.with_suffix(".png")
|
||||||
|
if result.returncode != 0 or not output_png.exists():
|
||||||
|
return None
|
||||||
|
shutil.move(str(output_png), cached_png)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
img = Image.open(cached_png)
|
||||||
|
img.thumbnail((size, size), Image.LANCZOS)
|
||||||
|
return ImageTk.PhotoImage(img)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
def _browser_preview_m2(self, path: str, entry: dict) -> None:
|
def _browser_preview_m2(self, path: str, entry: dict) -> None:
|
||||||
file_path = self._browser_resolve_path(path)
|
file_path = self._browser_resolve_path(path)
|
||||||
|
|
@ -872,10 +1032,7 @@ class AssetPipelineGUI:
|
||||||
|
|
||||||
version = struct.unpack_from("<I", data, 4)[0]
|
version = struct.unpack_from("<I", data, 4)[0]
|
||||||
|
|
||||||
# Parse vertex info from header
|
# Parse vertices
|
||||||
# Header layout: magic(4)+ver(4)+name(8)+flags(4)+globalSeq(8)+anim(8)+animLookup(8) = 44 bytes
|
|
||||||
# Then bones(8)+keyBone(8)+nVerts(4)+ofsVerts(4)
|
|
||||||
# Vanilla inserts playableAnimLookup(8) before bones, shifting everything +8
|
|
||||||
if version <= 256:
|
if version <= 256:
|
||||||
n_verts, ofs_verts = struct.unpack_from("<II", data, 68)
|
n_verts, ofs_verts = struct.unpack_from("<II", data, 68)
|
||||||
else:
|
else:
|
||||||
|
|
@ -891,26 +1048,166 @@ class AssetPipelineGUI:
|
||||||
x, y, z = struct.unpack_from("<fff", data, off)
|
x, y, z = struct.unpack_from("<fff", data, off)
|
||||||
verts.append((x, y, z))
|
verts.append((x, y, z))
|
||||||
|
|
||||||
# Try to find .skin file for indices
|
# Parse skin triangles
|
||||||
tris: list[tuple[int, int, int]] = []
|
tris: list[tuple[int, int, int]] = []
|
||||||
skin_path = file_path.with_name(file_path.stem + "00.skin")
|
skin_path = file_path.with_name(file_path.stem + "00.skin")
|
||||||
if skin_path.exists():
|
if skin_path.exists():
|
||||||
tris = self._parse_skin_triangles(skin_path.read_bytes())
|
tris = self._parse_skin_triangles(skin_path.read_bytes())
|
||||||
|
|
||||||
if not tris:
|
if not tris:
|
||||||
# No skin or no triangles — create point cloud edges from sequential vertices
|
|
||||||
for i in range(0, len(verts) - 1, 2):
|
for i in range(0, len(verts) - 1, 2):
|
||||||
tris.append((i, i + 1, i + 1))
|
tris.append((i, i + 1, i + 1))
|
||||||
|
|
||||||
|
# Parse textures and animations
|
||||||
|
textures = self._browser_parse_m2_textures(data, version)
|
||||||
|
animations = self._browser_parse_m2_animations(data, version)
|
||||||
|
|
||||||
|
# --- Layout ---
|
||||||
|
# Top bar: info label + 3D viewer button
|
||||||
|
top_bar = ttk.Frame(self._browser_preview_frame)
|
||||||
|
top_bar.pack(fill="x", pady=(4, 2))
|
||||||
|
|
||||||
|
info = f"M2 v{version} | {n_verts} verts, {len(tris)} tris | {len(textures)} textures, {len(animations)} anims"
|
||||||
|
ttk.Label(top_bar, text=info).pack(side="left", fill="x", expand=True)
|
||||||
|
|
||||||
|
def _open_3d_viewer(fp=file_path, tex_list=textures):
|
||||||
|
blp_convert = ROOT_DIR / "build" / "bin" / "blp_convert"
|
||||||
|
if not blp_convert.exists():
|
||||||
|
messagebox.showerror("Error", "blp_convert not found in build/bin/")
|
||||||
|
return
|
||||||
|
# Resolve BLP paths for type-0 textures
|
||||||
|
blp_map: dict[str, str] = {}
|
||||||
|
for tex in tex_list:
|
||||||
|
if tex["type"] == 0 and tex["filename"]:
|
||||||
|
fname = tex["filename"]
|
||||||
|
resolved = self._browser_resolve_blp_path(fname)
|
||||||
|
if resolved:
|
||||||
|
norm = fname.replace("\\", "/")
|
||||||
|
blp_map[norm] = str(resolved)
|
||||||
|
blp_map[norm.lower()] = str(resolved)
|
||||||
|
try:
|
||||||
|
from tools.m2_viewer import launch_m2_viewer
|
||||||
|
launch_m2_viewer(str(fp), blp_map, str(blp_convert))
|
||||||
|
except ImportError:
|
||||||
|
# Try relative import for when run as script
|
||||||
|
try:
|
||||||
|
from m2_viewer import launch_m2_viewer as lmv
|
||||||
|
lmv(str(fp), blp_map, str(blp_convert))
|
||||||
|
except ImportError:
|
||||||
|
messagebox.showerror("Error", "m2_viewer.py not found. Requires pygame, PyOpenGL, numpy, Pillow.")
|
||||||
|
|
||||||
|
ttk.Button(top_bar, text="Open 3D Viewer", command=_open_3d_viewer).pack(side="right", padx=4)
|
||||||
|
|
||||||
|
# Main area: wireframe (left) + sidebar (right)
|
||||||
|
main_pane = ttk.PanedWindow(self._browser_preview_frame, orient="horizontal")
|
||||||
|
main_pane.pack(fill="both", expand=True)
|
||||||
|
|
||||||
|
# Left: wireframe canvas
|
||||||
|
left_frame = ttk.Frame(main_pane)
|
||||||
|
main_pane.add(left_frame, weight=3)
|
||||||
|
|
||||||
self._browser_wireframe_verts = verts
|
self._browser_wireframe_verts = verts
|
||||||
self._browser_wireframe_tris = tris
|
self._browser_wireframe_tris = tris
|
||||||
self._browser_az = 0.0
|
self._browser_az = 0.0
|
||||||
self._browser_el = 0.3
|
self._browser_el = 0.3
|
||||||
self._browser_zoom = 1.0
|
self._browser_zoom = 1.0
|
||||||
|
|
||||||
info = f"M2 v{version}: {n_verts} vertices, {len(tris)} triangles"
|
canvas = tk.Canvas(left_frame, bg="#1a1a2e", highlightthickness=0)
|
||||||
ttk.Label(self._browser_preview_frame, text=info).pack(pady=(4, 2))
|
canvas.pack(fill="both", expand=True)
|
||||||
self._browser_create_wireframe_canvas()
|
self._browser_canvas = canvas
|
||||||
|
|
||||||
|
canvas.bind("<Button-1>", self._browser_wf_mouse_down)
|
||||||
|
canvas.bind("<B1-Motion>", self._browser_wf_mouse_drag)
|
||||||
|
canvas.bind("<MouseWheel>", self._browser_wf_scroll)
|
||||||
|
canvas.bind("<Button-4>", lambda e: self._browser_wf_scroll_linux(e, 1))
|
||||||
|
canvas.bind("<Button-5>", lambda e: self._browser_wf_scroll_linux(e, -1))
|
||||||
|
canvas.bind("<Configure>", lambda e: self._browser_wf_render())
|
||||||
|
self.root.after(50, self._browser_wf_render)
|
||||||
|
|
||||||
|
# Right: textures + animations sidebar
|
||||||
|
right_frame = ttk.Frame(main_pane)
|
||||||
|
main_pane.add(right_frame, weight=1)
|
||||||
|
|
||||||
|
# --- Textures section ---
|
||||||
|
ttk.Label(right_frame, text="Textures", font=("", 10, "bold")).pack(anchor="w", pady=(4, 2))
|
||||||
|
|
||||||
|
# Keep references to thumbnail PhotoImages to prevent GC
|
||||||
|
self._browser_m2_thumbs: list[Any] = []
|
||||||
|
|
||||||
|
if textures:
|
||||||
|
tex_frame = ttk.Frame(right_frame)
|
||||||
|
tex_frame.pack(fill="x", padx=2)
|
||||||
|
|
||||||
|
for i, tex in enumerate(textures):
|
||||||
|
row_frame = ttk.Frame(tex_frame)
|
||||||
|
row_frame.pack(fill="x", pady=1)
|
||||||
|
|
||||||
|
tex_type = tex["type"]
|
||||||
|
filename = tex["filename"]
|
||||||
|
|
||||||
|
if tex_type == 0 and filename:
|
||||||
|
# Try to load BLP thumbnail
|
||||||
|
display_name = filename.replace("\\", "/").split("/")[-1]
|
||||||
|
blp_fs_path = self._browser_resolve_blp_path(filename)
|
||||||
|
thumb = None
|
||||||
|
if blp_fs_path:
|
||||||
|
thumb = self._browser_load_blp_thumbnail(blp_fs_path)
|
||||||
|
|
||||||
|
if thumb:
|
||||||
|
self._browser_m2_thumbs.append(thumb)
|
||||||
|
lbl_img = ttk.Label(row_frame, image=thumb)
|
||||||
|
lbl_img.pack(side="left", padx=(0, 4))
|
||||||
|
|
||||||
|
lbl_text = ttk.Label(row_frame, text=display_name, wraplength=180)
|
||||||
|
lbl_text.pack(side="left", fill="x")
|
||||||
|
else:
|
||||||
|
type_name = self._TEX_TYPE_NAMES.get(tex_type, f"Type {tex_type}")
|
||||||
|
lbl = ttk.Label(row_frame, text=f"[{type_name}]", foreground="#888")
|
||||||
|
lbl.pack(side="left")
|
||||||
|
else:
|
||||||
|
ttk.Label(right_frame, text="(none)", foreground="#888").pack(anchor="w")
|
||||||
|
|
||||||
|
# --- Separator ---
|
||||||
|
ttk.Separator(right_frame, orient="horizontal").pack(fill="x", pady=6)
|
||||||
|
|
||||||
|
# --- Animations section ---
|
||||||
|
ttk.Label(right_frame, text="Animations", font=("", 10, "bold")).pack(anchor="w", pady=(0, 2))
|
||||||
|
|
||||||
|
if animations:
|
||||||
|
anim_frame = ttk.Frame(right_frame)
|
||||||
|
anim_frame.pack(fill="both", expand=True)
|
||||||
|
|
||||||
|
anim_scroll = ttk.Scrollbar(anim_frame, orient="vertical")
|
||||||
|
anim_tree = ttk.Treeview(
|
||||||
|
anim_frame, columns=("id", "name", "var", "dur", "spd"),
|
||||||
|
show="headings", height=8,
|
||||||
|
yscrollcommand=anim_scroll.set,
|
||||||
|
)
|
||||||
|
anim_scroll.config(command=anim_tree.yview)
|
||||||
|
|
||||||
|
anim_tree.heading("id", text="ID")
|
||||||
|
anim_tree.heading("name", text="Name")
|
||||||
|
anim_tree.heading("var", text="Var")
|
||||||
|
anim_tree.heading("dur", text="Dur(ms)")
|
||||||
|
anim_tree.heading("spd", text="Speed")
|
||||||
|
|
||||||
|
anim_tree.column("id", width=35, minwidth=30)
|
||||||
|
anim_tree.column("name", width=90, minwidth=60)
|
||||||
|
anim_tree.column("var", width=30, minwidth=25)
|
||||||
|
anim_tree.column("dur", width=55, minwidth=40)
|
||||||
|
anim_tree.column("spd", width=45, minwidth=35)
|
||||||
|
|
||||||
|
for anim in animations:
|
||||||
|
aid = anim["id"]
|
||||||
|
name = self._ANIM_NAMES.get(aid, "")
|
||||||
|
anim_tree.insert("", "end", values=(
|
||||||
|
aid, name, anim["variation"],
|
||||||
|
anim["duration"], f"{anim['speed']:.1f}",
|
||||||
|
))
|
||||||
|
|
||||||
|
anim_tree.pack(side="left", fill="both", expand=True)
|
||||||
|
anim_scroll.pack(side="right", fill="y")
|
||||||
|
else:
|
||||||
|
ttk.Label(right_frame, text="(none)", foreground="#888").pack(anchor="w")
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
ttk.Label(self._browser_preview_frame, text=f"M2 parse error: {exc}").pack(expand=True)
|
ttk.Label(self._browser_preview_frame, text=f"M2 parse error: {exc}").pack(expand=True)
|
||||||
|
|
@ -995,8 +1292,74 @@ class AssetPipelineGUI:
|
||||||
self._browser_el = 0.3
|
self._browser_el = 0.3
|
||||||
self._browser_zoom = 1.0
|
self._browser_zoom = 1.0
|
||||||
|
|
||||||
|
top_bar = ttk.Frame(self._browser_preview_frame)
|
||||||
|
top_bar.pack(fill="x", pady=(4, 2))
|
||||||
|
|
||||||
info = f"WMO: {len(verts)} vertices, {len(tris)} triangles"
|
info = f"WMO: {len(verts)} vertices, {len(tris)} triangles"
|
||||||
ttk.Label(self._browser_preview_frame, text=info).pack(pady=(4, 2))
|
ttk.Label(top_bar, text=info).pack(side="left", fill="x", expand=True)
|
||||||
|
|
||||||
|
def _open_wmo_viewer(fp=file_path, ig=is_group):
|
||||||
|
blp_convert = ROOT_DIR / "build" / "bin" / "blp_convert"
|
||||||
|
if not blp_convert.exists():
|
||||||
|
messagebox.showerror("Error", "blp_convert not found in build/bin/")
|
||||||
|
return
|
||||||
|
# Determine root and group files
|
||||||
|
if ig:
|
||||||
|
stem = fp.stem
|
||||||
|
root_stem = stem.rsplit("_", 1)[0]
|
||||||
|
root_path = fp.parent / f"{root_stem}.wmo"
|
||||||
|
groups = sorted(fp.parent.glob(f"{root_stem}_*.wmo"))
|
||||||
|
else:
|
||||||
|
root_path = fp
|
||||||
|
groups = sorted(fp.parent.glob(f"{fp.stem}_*.wmo"))
|
||||||
|
# Parse root for texture names, resolve BLP paths
|
||||||
|
blp_map: dict[str, str] = {}
|
||||||
|
if root_path.exists():
|
||||||
|
import struct as _st
|
||||||
|
rdata = root_path.read_bytes()
|
||||||
|
pos = 0
|
||||||
|
while pos + 8 <= len(rdata):
|
||||||
|
cid = rdata[pos:pos + 4]
|
||||||
|
csz = _st.unpack_from("<I", rdata, pos + 4)[0]
|
||||||
|
cs = pos + 8
|
||||||
|
ce = cs + csz
|
||||||
|
if ce > len(rdata):
|
||||||
|
break
|
||||||
|
tag = cid if cid[:1].isupper() else cid[::-1]
|
||||||
|
if tag == b"MOTX":
|
||||||
|
off = 0
|
||||||
|
while off < csz:
|
||||||
|
end = rdata.find(b"\x00", cs + off, ce)
|
||||||
|
if end < 0:
|
||||||
|
break
|
||||||
|
s = rdata[cs + off:end].decode("ascii", errors="replace")
|
||||||
|
if s:
|
||||||
|
resolved = self._browser_resolve_blp_path(s)
|
||||||
|
if resolved:
|
||||||
|
norm = s.replace("\\", "/")
|
||||||
|
blp_map[norm] = str(resolved)
|
||||||
|
blp_map[norm.lower()] = str(resolved)
|
||||||
|
off = end - cs + 1
|
||||||
|
else:
|
||||||
|
off += 1
|
||||||
|
break
|
||||||
|
pos = ce
|
||||||
|
try:
|
||||||
|
from tools.m2_viewer import launch_wmo_viewer
|
||||||
|
launch_wmo_viewer(
|
||||||
|
str(root_path) if root_path.exists() else "",
|
||||||
|
[str(g) for g in groups],
|
||||||
|
blp_map, str(blp_convert))
|
||||||
|
except ImportError:
|
||||||
|
try:
|
||||||
|
from m2_viewer import launch_wmo_viewer as lwv
|
||||||
|
lwv(str(root_path) if root_path.exists() else "",
|
||||||
|
[str(g) for g in groups], blp_map, str(blp_convert))
|
||||||
|
except ImportError:
|
||||||
|
messagebox.showerror("Error", "m2_viewer.py not found. Requires pygame, PyOpenGL, numpy, Pillow.")
|
||||||
|
|
||||||
|
ttk.Button(top_bar, text="Open 3D Viewer", command=_open_wmo_viewer).pack(side="right", padx=4)
|
||||||
|
|
||||||
self._browser_create_wireframe_canvas()
|
self._browser_create_wireframe_canvas()
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
@ -1423,7 +1786,40 @@ class AssetPipelineGUI:
|
||||||
|
|
||||||
text = "\n".join(info_lines)
|
text = "\n".join(info_lines)
|
||||||
lbl = ttk.Label(self._browser_preview_frame, text=text, justify="left", anchor="nw")
|
lbl = ttk.Label(self._browser_preview_frame, text=text, justify="left", anchor="nw")
|
||||||
lbl.pack(expand=True, padx=20, pady=20)
|
lbl.pack(padx=20, pady=(20, 8))
|
||||||
|
|
||||||
|
# Audio playback controls
|
||||||
|
btn_frame = ttk.Frame(self._browser_preview_frame)
|
||||||
|
btn_frame.pack(padx=20, pady=4)
|
||||||
|
|
||||||
|
self._audio_status_var = tk.StringVar(value="Stopped")
|
||||||
|
status_lbl = ttk.Label(self._browser_preview_frame, textvariable=self._audio_status_var)
|
||||||
|
status_lbl.pack(padx=20, pady=(4, 0))
|
||||||
|
|
||||||
|
def _play_audio():
|
||||||
|
self._browser_stop_audio()
|
||||||
|
try:
|
||||||
|
import multiprocessing
|
||||||
|
self._audio_proc = multiprocessing.Process(
|
||||||
|
target=_audio_subprocess, args=(str(file_path),), daemon=True)
|
||||||
|
self._audio_proc.start()
|
||||||
|
self._audio_status_var.set("Playing...")
|
||||||
|
except Exception as exc:
|
||||||
|
self._audio_status_var.set(f"Error: {exc}")
|
||||||
|
|
||||||
|
def _stop_audio():
|
||||||
|
self._browser_stop_audio()
|
||||||
|
self._audio_status_var.set("Stopped")
|
||||||
|
|
||||||
|
ttk.Button(btn_frame, text="Play", command=_play_audio).pack(side="left", padx=4)
|
||||||
|
ttk.Button(btn_frame, text="Stop", command=_stop_audio).pack(side="left", padx=4)
|
||||||
|
|
||||||
|
def _browser_stop_audio(self):
|
||||||
|
proc = getattr(self, "_audio_proc", None)
|
||||||
|
if proc and proc.is_alive():
|
||||||
|
proc.terminate()
|
||||||
|
proc.join(timeout=1)
|
||||||
|
self._audio_proc = None
|
||||||
|
|
||||||
# ── Hex Dump Preview ──
|
# ── Hex Dump Preview ──
|
||||||
|
|
||||||
|
|
|
||||||
2170
tools/m2_viewer.py
Normal file
2170
tools/m2_viewer.py
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue