Skip to content

Commit 0d8fd25

Browse files
committed
feat: implement asset manager and exporter modules with file move and dependency resolution support
1 parent 9ed2b0d commit 0d8fd25

23 files changed

Lines changed: 1529 additions & 20 deletions

src/editors/asset_exporter/__init__.py

Whitespace-only changes.

src/editors/asset_exporter/context_menu.py

Whitespace-only changes.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import os
2+
from src.common import Kv3ToJson
3+
4+
class DependencyResolver:
5+
ASSET_DEPENDENCY_KEYS = [
6+
# vmdl / vsmart
7+
'm_refMeshes', 'm_refPhysicsData', 'model',
8+
# vmat
9+
'TextureColor', 'TextureNormal', 'TextureRoughness',
10+
'TextureMetalness', 'g_tColor', 'g_tNormal',
11+
# generic
12+
'material', 'mesh', 'texture', 'm_strPsd'
13+
]
14+
15+
def __init__(self, addon_content_path: str):
16+
self.addon_content_path = addon_content_path
17+
self._visited: set[str] = set()
18+
self.missing_deps: set[str] = set()
19+
20+
def resolve(self, asset_path: str) -> list[str]:
21+
self._visited.clear()
22+
self.missing_deps.clear()
23+
self._walk(asset_path)
24+
return sorted(list(self._visited))
25+
26+
def _walk(self, path: str):
27+
path = os.path.normpath(path)
28+
if path in self._visited or not os.path.isfile(path):
29+
return
30+
self._visited.add(path)
31+
ext = os.path.splitext(path)[1].lower()
32+
if ext in ('.vmdl', '.vsmart', '.vmat', '.vpcf', '.vsndevts', '.vtex', '.vsnd'):
33+
self._parse_kv3_deps(path)
34+
35+
def _parse_kv3_deps(self, path: str):
36+
try:
37+
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
38+
content = f.read()
39+
# If it's pure binary or random data, kv3 parsing will fail, which is fine
40+
# We catch exceptions
41+
data = Kv3ToJson(content)
42+
if data:
43+
self._find_deps_in_dict(data)
44+
except Exception as e:
45+
print(f"DependencyResolver: Error parsing {path}: {e}")
46+
47+
def _find_deps_in_dict(self, data):
48+
if isinstance(data, dict):
49+
for k, v in data.items():
50+
if k in self.ASSET_DEPENDENCY_KEYS and isinstance(v, str):
51+
self._add_dep(v)
52+
else:
53+
self._find_deps_in_dict(v)
54+
elif isinstance(data, list):
55+
for item in data:
56+
self._find_deps_in_dict(item)
57+
58+
def _add_dep(self, rel_path: str):
59+
if not rel_path:
60+
return
61+
62+
# Handle compiled asset path mapping
63+
if rel_path.endswith('_c'):
64+
rel_path = rel_path[:-2]
65+
66+
rel_path = rel_path.replace('//', '/').replace('\\', '/')
67+
item_path = os.path.join(self.addon_content_path, rel_path)
68+
item_path = os.path.normpath(item_path)
69+
if os.path.exists(item_path):
70+
self._walk(item_path)
71+
else:
72+
self.missing_deps.add(rel_path)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import os
2+
import shutil
3+
from PySide6.QtCore import QThread, Signal
4+
5+
class ExportWorker(QThread):
6+
progress = Signal(int)
7+
file_copied = Signal(str)
8+
finished_export = Signal(str)
9+
10+
def __init__(self, files, addon_content_path, dest_root, layout, addon_name, asset_stem):
11+
super().__init__()
12+
self.files = files
13+
self.addon_content_path = addon_content_path
14+
self.dest_root = dest_root
15+
self.layout = layout
16+
self.addon_name = addon_name
17+
self.asset_stem = asset_stem
18+
19+
def run(self):
20+
for i, src in enumerate(self.files):
21+
dest = self._compute_dest(src)
22+
os.makedirs(os.path.dirname(dest), exist_ok=True)
23+
try:
24+
shutil.copy2(src, dest)
25+
self.file_copied.emit(src)
26+
except Exception as e:
27+
print(f"Error copying {src} to {dest}: {e}")
28+
self.progress.emit(int((i + 1) / len(self.files) * 100))
29+
self.finished_export.emit(self.dest_root)
30+
31+
def _compute_dest(self, src):
32+
rel = os.path.relpath(src, self.addon_content_path)
33+
if self.layout == 'thirdparty':
34+
return os.path.join(self.dest_root,
35+
'folder_thirdparty', self.addon_name,
36+
self.asset_stem, rel)
37+
return os.path.join(self.dest_root, rel)

src/editors/asset_exporter/main.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import os
2+
from PySide6.QtWidgets import QWidget, QFileSystemModel, QFileDialog, QMessageBox, QDockWidget, QMainWindow
3+
from PySide6.QtCore import Qt, QItemSelectionModel
4+
from PySide6.QtGui import QStandardItemModel, QStandardItem
5+
from .ui_main import Ui_AssetExporterWidget
6+
from .dependency_resolver import DependencyResolver
7+
from .exporter import ExportWorker
8+
from src.settings.main import get_addon_name, get_cs2_path
9+
from PySide6.QtWidgets import QLineEdit
10+
from src.common import enable_dark_title_bar
11+
from src.styles.common import apply_stylesheets, qt_stylesheet_widgetlist2
12+
13+
class AssetExporterWidget(QWidget):
14+
def __init__(self, parent=None):
15+
super().__init__(parent)
16+
self.ui = Ui_AssetExporterWidget()
17+
self.ui.setupUi(self)
18+
19+
enable_dark_title_bar(self)
20+
apply_stylesheets(self)
21+
22+
qt_stylesheet_lineedit = """
23+
QLineEdit {
24+
font: 580 10pt "Segoe UI";
25+
border: 2px solid black;
26+
border-radius: 2px;
27+
border-color: rgba(80, 80, 80, 255);
28+
height:22px;
29+
padding-top: 2px;
30+
padding-bottom:2px;
31+
padding-left: 4px;
32+
padding-right: 4px;
33+
color: #E3E3E3;
34+
background-color: #1C1C1C;
35+
}
36+
QLineEdit:hover {
37+
background-color: #414956;
38+
color: white;
39+
}
40+
"""
41+
for line_edit in self.findChildren(QLineEdit):
42+
line_edit.setStyleSheet(qt_stylesheet_lineedit)
43+
44+
self.ui.deps_list.setStyleSheet(qt_stylesheet_widgetlist2)
45+
46+
self.cs2_path = get_cs2_path()
47+
if not self.cs2_path:
48+
return
49+
50+
self.addon_name = get_addon_name()
51+
self.addon_content_path = os.path.join(self.cs2_path, 'content', 'csgo_addons', self.addon_name)
52+
53+
self.setWindowFlags(Qt.Window)
54+
self.setWindowTitle("Export Asset")
55+
self.ui.source_tree.hide()
56+
self.ui.btn_resolve.hide()
57+
self.sources_to_export = []
58+
59+
# Setup source tree model
60+
self.source_model = QFileSystemModel()
61+
self.source_model.setRootPath(self.addon_content_path)
62+
# Filter for supported types
63+
self.source_model.setNameFilters(['*.vmdl', '*.vsmart', '*.vmat', '*.vpcf', '*.vsndevts', '*.vsnd', '*.vtex'])
64+
self.source_model.setNameFilterDisables(False)
65+
66+
self.ui.source_tree.setModel(self.source_model)
67+
self.ui.source_tree.setRootIndex(self.source_model.index(self.addon_content_path))
68+
self.ui.source_tree.setColumnWidth(0, 250)
69+
70+
# Connect signals
71+
self.ui.btn_resolve.clicked.connect(self.resolve_dependencies)
72+
self.ui.btn_browse.clicked.connect(self.browse_output_dir)
73+
self.ui.btn_export.clicked.connect(self.start_export)
74+
self.ui.radio_thirdparty.toggled.connect(self.toggle_thirdparty_fields)
75+
self.ui.radio_preserve.toggled.connect(self.toggle_thirdparty_fields)
76+
77+
self.toggle_thirdparty_fields()
78+
79+
self.deps_model = QStandardItemModel()
80+
self.ui.deps_list.setModel(self.deps_model)
81+
82+
def toggle_thirdparty_fields(self):
83+
is_thirdparty = self.ui.radio_thirdparty.isChecked()
84+
self.ui.edit_addon_name.setVisible(is_thirdparty)
85+
self.ui.edit_asset_stem.setVisible(is_thirdparty)
86+
87+
def select_file(self, full_paths):
88+
"""Automatically select files and resolve their dependencies."""
89+
if isinstance(full_paths, str):
90+
full_paths = [full_paths]
91+
self.sources_to_export = full_paths
92+
self.resolve_dependencies()
93+
94+
def resolve_dependencies(self):
95+
if not self.sources_to_export:
96+
return
97+
98+
self.deps_model.clear()
99+
100+
resolver = DependencyResolver(self.addon_content_path)
101+
resolved_files = set()
102+
all_missing_deps = set()
103+
104+
for path in self.sources_to_export:
105+
if os.path.isfile(path):
106+
deps = resolver.resolve(path)
107+
resolved_files.update(deps)
108+
all_missing_deps.update(resolver.missing_deps)
109+
110+
for dep in sorted(list(resolved_files)):
111+
item = QStandardItem(os.path.relpath(dep, self.addon_content_path))
112+
item.setCheckable(True)
113+
item.setCheckState(Qt.Checked)
114+
item.setData(dep, Qt.UserRole)
115+
self.deps_model.appendRow(item)
116+
117+
if all_missing_deps:
118+
missing_str = "\n".join(list(all_missing_deps)[:10])
119+
if len(all_missing_deps) > 10:
120+
missing_str += f"\n... and {len(all_missing_deps) - 10} more."
121+
QMessageBox.warning(
122+
self,
123+
"Missing Dependencies",
124+
f"The following dependencies could not be found and will not be exported:\n\n{missing_str}"
125+
)
126+
127+
def browse_output_dir(self):
128+
d = QFileDialog.getExistingDirectory(self, "Select Output Directory")
129+
if d:
130+
self.ui.edit_output_dir.setText(os.path.normpath(d))
131+
132+
def start_export(self):
133+
output_dir = self.ui.edit_output_dir.text()
134+
if not output_dir:
135+
QMessageBox.warning(self, "Warning", "Please select an output directory.")
136+
return
137+
138+
is_thirdparty = self.ui.radio_thirdparty.isChecked()
139+
addon_name = self.ui.edit_addon_name.text()
140+
asset_stem = self.ui.edit_asset_stem.text()
141+
142+
if is_thirdparty and (not addon_name or not asset_stem):
143+
QMessageBox.warning(self, "Warning", "Please enter Addon Name and Asset Stem for Third-Party layout.")
144+
return
145+
146+
files_to_export = []
147+
for row in range(self.deps_model.rowCount()):
148+
item = self.deps_model.item(row)
149+
if item.checkState() == Qt.Checked:
150+
files_to_export.append(item.data(Qt.UserRole))
151+
152+
if not files_to_export:
153+
QMessageBox.warning(self, "Warning", "No files selected to export.")
154+
return
155+
156+
layout = 'thirdparty' if is_thirdparty else 'preserve'
157+
158+
self.worker = ExportWorker(
159+
files_to_export,
160+
self.addon_content_path,
161+
output_dir,
162+
layout,
163+
addon_name,
164+
asset_stem
165+
)
166+
self.worker.progress.connect(self.ui.progress_bar.setValue)
167+
self.worker.finished_export.connect(self.on_export_finished)
168+
self.ui.btn_export.setEnabled(False)
169+
self.ui.progress_bar.setValue(0)
170+
self.worker.start()
171+
172+
def on_export_finished(self, dest_root):
173+
self.ui.btn_export.setEnabled(True)
174+
QMessageBox.information(self, "Success", "Export finished successfully.")
175+
os.startfile(dest_root)

src/editors/asset_exporter/main.ui

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<ui version="4.0">
3+
<class>AssetExporterWidget</class>
4+
<widget class="QWidget" name="AssetExporterWidget">
5+
<property name="geometry">
6+
<rect>
7+
<x>0</x>
8+
<y>0</y>
9+
<width>800</width>
10+
<height>600</height>
11+
</rect>
12+
</property>
13+
<property name="windowTitle">
14+
<string>Asset Exporter</string>
15+
</property>
16+
<layout class="QHBoxLayout" name="horizontalLayout">
17+
<item>
18+
<widget class="QTreeView" name="source_tree"/>
19+
</item>
20+
<item>
21+
<layout class="QVBoxLayout" name="verticalLayout">
22+
<item>
23+
<widget class="QPushButton" name="btn_resolve">
24+
<property name="text">
25+
<string>Resolve Dependencies</string>
26+
</property>
27+
</widget>
28+
</item>
29+
<item>
30+
<widget class="QTreeView" name="deps_list"/>
31+
</item>
32+
<item>
33+
<widget class="QGroupBox" name="groupBox">
34+
<property name="title">
35+
<string>Export Options</string>
36+
</property>
37+
<layout class="QVBoxLayout" name="verticalLayout_2">
38+
<item>
39+
<widget class="QRadioButton" name="radio_preserve">
40+
<property name="text">
41+
<string>Preserve Addon Structure</string>
42+
</property>
43+
<property name="checked">
44+
<bool>true</bool>
45+
</property>
46+
</widget>
47+
</item>
48+
<item>
49+
<widget class="QRadioButton" name="radio_thirdparty">
50+
<property name="text">
51+
<string>Third-Party Package Layout (folder_thirdparty/...)</string>
52+
</property>
53+
</widget>
54+
</item>
55+
<item>
56+
<widget class="QLineEdit" name="edit_addon_name">
57+
<property name="placeholderText">
58+
<string>Addon Name</string>
59+
</property>
60+
</widget>
61+
</item>
62+
<item>
63+
<widget class="QLineEdit" name="edit_asset_stem">
64+
<property name="placeholderText">
65+
<string>Asset Stem</string>
66+
</property>
67+
</widget>
68+
</item>
69+
</layout>
70+
</widget>
71+
</item>
72+
<item>
73+
<layout class="QHBoxLayout" name="horizontalLayout_2">
74+
<item>
75+
<widget class="QLineEdit" name="edit_output_dir">
76+
<property name="placeholderText">
77+
<string>Output Directory</string>
78+
</property>
79+
</widget>
80+
</item>
81+
<item>
82+
<widget class="QPushButton" name="btn_browse">
83+
<property name="text">
84+
<string>Browse...</string>
85+
</property>
86+
</widget>
87+
</item>
88+
</layout>
89+
</item>
90+
<item>
91+
<widget class="QProgressBar" name="progress_bar">
92+
<property name="value">
93+
<number>0</number>
94+
</property>
95+
</widget>
96+
</item>
97+
<item>
98+
<widget class="QPushButton" name="btn_export">
99+
<property name="text">
100+
<string>Export</string>
101+
</property>
102+
</widget>
103+
</item>
104+
</layout>
105+
</item>
106+
</layout>
107+
</widget>
108+
<resources/>
109+
<connections/>
110+
</ui>

0 commit comments

Comments
 (0)