Skip to content

Commit 1fc2473

Browse files
committed
Release v0.8.6
1 parent 51e3944 commit 1fc2473

8 files changed

Lines changed: 429 additions & 20 deletions

CHANGELOG.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.8.6] - 2026-05-05
11+
12+
Mesh preview tab plus type-aware right-click previews across the Unpacker
13+
and PSK Picker.
14+
15+
### Added
16+
- 3D Mesh Preview tab — full 360° orbit, see-through wireframe, fast load.
17+
- Right-click "Preview Mesh / Texture / Audio / Properties" in the Unpacker;
18+
unexpanded `.uasset` rows offer all kinds, expanded rows show only the
19+
preview buttons matching their contained types.
20+
- Right-click "Preview Mesh" + "Open containing folder" in the PSK Picker.
21+
- Auto-detect UE version from the game executable's `FileVersionInfo`.
22+
23+
### Changed
24+
- More descriptive preview-failure status — "This asset has no <kind> data
25+
to preview" instead of "file exported but file not found".
26+
- Optional dependencies cleanup and minor UI polish.
27+
28+
### Fixed
29+
- First mesh preview no longer fails with a stale "file not found" — the
30+
resolver rescans the temp dir for both flat and nested CLI layouts.
31+
- Mesh previewer: UV-grid crash and wireframe leak between loads.
32+
- Build script clears stale .NET artifacts before publishing CUE4ParseCLI.
33+
1034
## [0.8.5] - 2026-05-05
1135

1236
Resilience and UX pass on top of v0.8.0. Full notes:
@@ -194,7 +218,8 @@ Initial public beta release.
194218
`requires_dotnet_cli` e2e markers.
195219
- **MIT License** and legal disclaimer for asset extraction usage.
196220

197-
[Unreleased]: https://github.com/exterminathan/EfficientAssetRipper/compare/v0.8.5...HEAD
221+
[Unreleased]: https://github.com/exterminathan/EfficientAssetRipper/compare/v0.8.6...HEAD
222+
[0.8.6]: https://github.com/exterminathan/EfficientAssetRipper/releases/tag/v0.8.6
198223
[0.8.5]: https://github.com/exterminathan/EfficientAssetRipper/releases/tag/v0.8.5
199224
[0.8.0]: https://github.com/exterminathan/EfficientAssetRipper/releases/tag/v0.8.0
200225
[0.5.0]: https://github.com/exterminathan/EfficientAssetRipper/releases/tag/v0.5.0

_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@
88
- CHANGELOG.md — top entry must match
99
"""
1010

11-
__version__ = "0.8.5"
11+
__version__ = "0.8.6"

gui/main_window.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,7 @@ def _build_ui(self):
407407
self._browser.mesh_preview_requested.connect(self._on_browser_mesh_preview)
408408
self._browser.props_view_requested.connect(self._on_browser_props_view)
409409
self._psk_picker.add_to_queue_requested.connect(self._add_picker_to_queue)
410+
self._psk_picker.mesh_preview_requested.connect(self._on_mesh_preview)
410411
self._unpacker_panel.psk_extracted.connect(self._on_psks_extracted)
411412
self._unpacker_panel.log_message.connect(self._log.append)
412413
self._unpacker_panel.version_mismatch.connect(self._log.show_alert)

gui/psk_picker.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,16 @@
1313
from collections import defaultdict
1414
from pathlib import Path
1515

16-
from PySide6.QtCore import Qt, QThread, QTimer, Signal, Slot
17-
from PySide6.QtGui import QColor
16+
from PySide6.QtCore import Qt, QThread, QTimer, QUrl, Signal, Slot
17+
from PySide6.QtGui import QAction, QColor, QDesktopServices
1818
from PySide6.QtWidgets import (
1919
QAbstractItemView,
2020
QCheckBox,
2121
QComboBox,
2222
QHBoxLayout,
2323
QLabel,
2424
QLineEdit,
25+
QMenu,
2526
QMessageBox,
2627
QPushButton,
2728
QTreeWidget,
@@ -69,6 +70,7 @@ class PskPickerPanel(QWidget):
6970
"""Tree-based PSK/PSKX file picker grouped by category/subcategory."""
7071

7172
add_to_queue_requested = Signal(list) # list[Path] – raw PSK paths
73+
mesh_preview_requested = Signal(str) # absolute .psk/.pskx path
7274

7375
def __init__(self, parent=None):
7476
super().__init__(parent)
@@ -182,6 +184,8 @@ def __init__(self, parent=None):
182184
)
183185
self._tree.setAlternatingRowColors(True)
184186
self._tree.itemChanged.connect(self._on_item_changed)
187+
self._tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
188+
self._tree.customContextMenuRequested.connect(self._show_context_menu)
185189
layout.addWidget(self._tree)
186190

187191
# --- Status ---
@@ -439,6 +443,39 @@ def _update_sel_count(self):
439443
n = len(self._checked_paths)
440444
self._sel_count.setText(f"{n} selected" if n else "")
441445

446+
def _show_context_menu(self, pos):
447+
item = self._tree.itemAt(pos)
448+
if item is None:
449+
return
450+
self._popup_context_menu(item, self._tree.viewport().mapToGlobal(pos))
451+
452+
def _popup_context_menu(self, item: QTreeWidgetItem, global_pos):
453+
"""Build and show the right-click menu for *item*. Split out from
454+
`_show_context_menu` so tests can drive it directly without poking
455+
the QTreeWidget's internal hit-testing."""
456+
idx = self._item_to_idx.get(id(item))
457+
if idx is None:
458+
return # category / subcategory header — no PSK to act on
459+
psk_path = self._all_paths[idx]
460+
461+
menu = QMenu(self)
462+
463+
act_preview = QAction("Preview Mesh", self)
464+
act_preview.triggered.connect(
465+
lambda checked=False, p=psk_path: self.mesh_preview_requested.emit(str(p))
466+
)
467+
menu.addAction(act_preview)
468+
469+
act_reveal = QAction("Open containing folder", self)
470+
act_reveal.triggered.connect(
471+
lambda checked=False, p=psk_path: QDesktopServices.openUrl(
472+
QUrl.fromLocalFile(str(p.parent))
473+
)
474+
)
475+
menu.addAction(act_reveal)
476+
477+
menu.exec(global_pos)
478+
442479
def _walk_leaves(self, parent: QTreeWidgetItem, out: list[int]):
443480
for i in range(parent.childCount()):
444481
child = parent.child(i)

gui/unpacker_panel.py

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ def __init__(self, parent=None):
9999
# Pending temp-export-for-preview state. Tuple is (expected_path, kind)
100100
# where kind ∈ {"audio", "mesh", "texture"} so _on_export_done can
101101
# dispatch to the right preview signal once the file lands on disk.
102-
self._pending_temp_preview: "tuple[str, str] | None" = None
102+
self._pending_temp_preview: "tuple[str, str, str] | None" = None
103103

104104
self._build_ui()
105105
self._connect_signals()
@@ -757,12 +757,33 @@ def _add_audio():
757757
elif kind == "audio":
758758
_add_audio()
759759
elif kind == "package":
760-
# Unexpanded .uasset/.upk/.umap — we don't know what's inside
761-
# without a list_exports round-trip, so offer all three preview
762-
# kinds and let the temp-export figure it out.
763-
_add_mesh()
764-
_add_texture()
765-
_add_audio()
760+
# If the package has been expanded, its children carry export_type
761+
# data. Show only the preview buttons matching what's actually
762+
# inside. If unexpanded (only the placeholder child exists), we
763+
# don't know yet — fall back to offering all three and let the
764+
# temp-export sort it out.
765+
is_unexpanded = (
766+
item.childCount() == 0
767+
or (
768+
item.childCount() == 1
769+
and item.child(0).data(0, Qt.ItemDataRole.UserRole) == _PLACEHOLDER
770+
)
771+
)
772+
if is_unexpanded:
773+
_add_mesh()
774+
_add_texture()
775+
_add_audio()
776+
else:
777+
child_kinds = {
778+
self._classify_row(item.child(i))
779+
for i in range(item.childCount())
780+
}
781+
if "mesh" in child_kinds:
782+
_add_mesh()
783+
if "texture" in child_kinds:
784+
_add_texture()
785+
if "audio" in child_kinds:
786+
_add_audio()
766787

767788
# Properties always available — the CLI's get_props returns inline
768789
# JSON regardless of asset type, so this never has to disable.
@@ -910,7 +931,7 @@ def _kick_temp_export(self, vfs_path: str, kind: str):
910931
formats[kind] = True
911932

912933
expected = self._predict_temp_output(Path(temp_dir), vfs_path, kind)
913-
self._pending_temp_preview = (str(expected), kind)
934+
self._pending_temp_preview = (str(expected), kind, vfs_path)
914935

915936
name = vfs_path.rsplit("/", 1)[-1] or vfs_path
916937
self._status_label.setText(f"Exporting for preview: {name}")
@@ -1097,7 +1118,7 @@ def _try_audio_preview(self, audio_data: dict):
10971118
"target_folder": full_folder,
10981119
}
10991120
expected = self._audio_preview_temp_dir / full_folder / f"{audio_data['debug_name']}.{audio_format}"
1100-
self._pending_temp_preview = (str(expected), "audio")
1121+
self._pending_temp_preview = (str(expected), "audio", audio_data["wem_vfs_path"])
11011122

11021123
self._status_label.setText(f"Exporting for preview: {audio_data['debug_name']}")
11031124
self._begin_export()
@@ -1310,7 +1331,7 @@ def _on_export_done(self, succeeded: list, failed: list):
13101331

13111332
# Handle temp-export-for-preview (audio / mesh / texture)
13121333
if self._pending_temp_preview:
1313-
expected, kind = self._pending_temp_preview
1334+
expected, kind, vfs_path = self._pending_temp_preview
13141335
self._pending_temp_preview = None
13151336
self._exporting = False
13161337
self._export_btn.setEnabled(True)
@@ -1341,13 +1362,23 @@ def _on_export_done(self, succeeded: list, failed: list):
13411362
if cand.is_file():
13421363
resolved = cand
13431364

1365+
# Final fallback: the CLI writes flat (basename only) but
1366+
# `succeeded` echoes the VFS input path, not the disk output.
1367+
# Rescan the temp dir for any candidate matching this vfs_path.
1368+
if resolved is None:
1369+
temp_dir = self._temp_dir_for_kind(kind)
1370+
if temp_dir is not None:
1371+
resolved = self._find_in_temp(Path(temp_dir), vfs_path, kind)
1372+
13441373
if resolved is not None and signal_for_kind is not None:
13451374
self._status_label.setText("Ready")
13461375
signal_for_kind.emit(str(resolved))
13471376
elif signal_for_kind is None:
13481377
self._status_label.setText(f"Preview kind {kind!r} has no signal")
13491378
else:
1350-
self._status_label.setText("Preview export completed but file not found")
1379+
self._status_label.setText(
1380+
f"This asset has no {kind} data to preview"
1381+
)
13511382
return
13521383

13531384
self._exporting = False
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""Tests for the PSK Picker's right-click context menu."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
from unittest.mock import patch
7+
8+
import pytest
9+
from PySide6.QtCore import QPoint
10+
from PySide6.QtGui import QDesktopServices
11+
from PySide6.QtWidgets import QMenu, QTreeWidgetItem
12+
13+
from gui.psk_picker import PskPickerPanel
14+
15+
pytestmark = pytest.mark.qt
16+
17+
18+
def _capture_menu_actions(panel: PskPickerPanel, item: QTreeWidgetItem) -> list[str]:
19+
"""Drive `_popup_context_menu` and return the list of action labels.
20+
Mirrors the same pattern used in the unpacker preview-menu tests."""
21+
captured: list[list[str]] = []
22+
orig_init = QMenu.__init__
23+
24+
def hooked(self, *args, **kwargs):
25+
orig_init(self, *args, **kwargs)
26+
def _rec(*a, **k):
27+
captured.append([action.text() for action in self.actions()])
28+
return None
29+
self.exec = _rec # type: ignore[assignment]
30+
31+
QMenu.__init__ = hooked # type: ignore[assignment]
32+
try:
33+
panel._popup_context_menu(item, QPoint(0, 0))
34+
finally:
35+
QMenu.__init__ = orig_init # type: ignore[assignment]
36+
return captured[0] if captured else []
37+
38+
39+
def _seed_picker(panel: PskPickerPanel, paths: list[Path]) -> list[QTreeWidgetItem]:
40+
"""Populate the picker with given paths and return the leaf tree items."""
41+
panel._all_paths = list(paths)
42+
panel._categories = [("TestCat", "TestSub") for _ in paths]
43+
panel._rebuild_tree()
44+
leaves: list[QTreeWidgetItem] = []
45+
root = panel._tree.invisibleRootItem()
46+
for ci in range(root.childCount()):
47+
cat = root.child(ci)
48+
for si in range(cat.childCount()):
49+
sub = cat.child(si)
50+
for li in range(sub.childCount()):
51+
leaves.append(sub.child(li))
52+
return leaves
53+
54+
55+
def test_menu_for_leaf_offers_preview_and_reveal(qtbot, tmp_path: Path):
56+
panel = PskPickerPanel()
57+
qtbot.addWidget(panel)
58+
psk = tmp_path / "Mesh.psk"
59+
psk.write_bytes(b"")
60+
leaves = _seed_picker(panel, [psk])
61+
assert leaves, "expected at least one leaf item after rebuild"
62+
63+
actions = _capture_menu_actions(panel, leaves[0])
64+
assert actions == ["Preview Mesh", "Open containing folder"]
65+
66+
67+
def test_menu_skipped_for_category_header(qtbot, tmp_path: Path):
68+
"""Category/subcategory headers aren't in `_item_to_idx`; the menu builder
69+
bails before any QMenu actions are added."""
70+
panel = PskPickerPanel()
71+
qtbot.addWidget(panel)
72+
psk = tmp_path / "Mesh.psk"
73+
psk.write_bytes(b"")
74+
_seed_picker(panel, [psk])
75+
76+
cat_item = panel._tree.invisibleRootItem().child(0)
77+
actions = _capture_menu_actions(panel, cat_item)
78+
assert actions == []
79+
80+
81+
def test_preview_action_emits_signal_with_psk_path(qtbot, tmp_path: Path):
82+
panel = PskPickerPanel()
83+
qtbot.addWidget(panel)
84+
psk = tmp_path / "Mesh.psk"
85+
psk.write_bytes(b"")
86+
leaves = _seed_picker(panel, [psk])
87+
88+
# Build the menu, find "Preview Mesh", trigger it, assert signal payload.
89+
captured: list[QMenu] = []
90+
orig_init = QMenu.__init__
91+
92+
def hooked(self, *args, **kwargs):
93+
orig_init(self, *args, **kwargs)
94+
captured.append(self)
95+
self.exec = lambda *a, **k: None # type: ignore[assignment]
96+
97+
QMenu.__init__ = hooked # type: ignore[assignment]
98+
try:
99+
with qtbot.waitSignal(panel.mesh_preview_requested, timeout=2000) as blocker:
100+
panel._popup_context_menu(leaves[0], QPoint(0, 0))
101+
menu = captured[0]
102+
preview_action = next(a for a in menu.actions() if a.text() == "Preview Mesh")
103+
preview_action.trigger()
104+
finally:
105+
QMenu.__init__ = orig_init # type: ignore[assignment]
106+
107+
assert blocker.args[0] == str(psk)
108+
109+
110+
def test_reveal_action_opens_parent_folder_via_qdesktopservices(qtbot, tmp_path: Path):
111+
panel = PskPickerPanel()
112+
qtbot.addWidget(panel)
113+
psk = tmp_path / "sub" / "Mesh.psk"
114+
psk.parent.mkdir(parents=True, exist_ok=True)
115+
psk.write_bytes(b"")
116+
leaves = _seed_picker(panel, [psk])
117+
118+
captured: list[QMenu] = []
119+
orig_init = QMenu.__init__
120+
121+
def hooked(self, *args, **kwargs):
122+
orig_init(self, *args, **kwargs)
123+
captured.append(self)
124+
self.exec = lambda *a, **k: None # type: ignore[assignment]
125+
126+
QMenu.__init__ = hooked # type: ignore[assignment]
127+
try:
128+
with patch.object(QDesktopServices, "openUrl") as mock_open:
129+
panel._popup_context_menu(leaves[0], QPoint(0, 0))
130+
menu = captured[0]
131+
reveal_action = next(
132+
a for a in menu.actions() if a.text() == "Open containing folder"
133+
)
134+
reveal_action.trigger()
135+
finally:
136+
QMenu.__init__ = orig_init # type: ignore[assignment]
137+
138+
assert mock_open.called
139+
url = mock_open.call_args[0][0]
140+
# QUrl.fromLocalFile produces file:// URLs; just check the local path.
141+
assert Path(url.toLocalFile()) == psk.parent

0 commit comments

Comments
 (0)