diff --git a/CMakeLists.txt b/CMakeLists.txt index 507b32410..cc87748a8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,6 +12,8 @@ endif() project(commando LANGUAGES C CXX) +include(CTest) + list(APPEND CMAKE_MODULE_PATH "${PROJECT_SOURCE_DIR}/cmake") # This file handles extra settings wanted/needed for different compilers. @@ -77,14 +79,14 @@ endif() if(W3D_BUILD_OPTION_SDL3) include(sdl3) - add_compile_definitions(-DOPENW3D_SDL3=1) + add_compile_definitions(OPENW3D_SDL3=1) elseif(WIN32) - add_compile_definitions(-DOPENW3D_WIN32=1) + add_compile_definitions(OPENW3D_WIN32=1) else() message(FATAL_ERROR "Invalid backend") endif() -add_compile_definitions(-DWEBBROWSER_ENABLED=$) +add_compile_definitions(WEBBROWSER_ENABLED=$) if(W3D_BUILD_OPTION_OPENAL) include(openal) @@ -106,7 +108,7 @@ endif() if(WIN32) # Do we want to build with bink for video decoding? - cmake_dependent_option(W3D_BUILD_OPTION_BINK "Build with bink." ON NOT W3D_BUILD_OPTION_FFMPEG OFF) + cmake_dependent_option(W3D_BUILD_OPTION_BINK "Build with bink." ON "NOT W3D_BUILD_OPTION_FFMPEG" OFF) add_feature_info(BinkBuild W3D_BUILD_OPTION_BINK "Build OpenW3D with Bink") if(W3D_BUILD_OPTION_BINK) @@ -115,7 +117,7 @@ if(WIN32) endif() # Do we want to build with miles for audio playback? - cmake_dependent_option(W3D_BUILD_OPTION_MILES "Build with miles." ON NOT W3D_BUILD_OPTION_OPENAL OFF) + cmake_dependent_option(W3D_BUILD_OPTION_MILES "Build with miles." ON "NOT W3D_BUILD_OPTION_OPENAL" OFF) add_feature_info(MilesBuild W3D_BUILD_OPTION_MILES "Build OpenW3D with Miles Audio") if(W3D_BUILD_OPTION_MILES) diff --git a/CMakePresets.json b/CMakePresets.json index 0cbab3f9b..69e609c20 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -41,7 +41,10 @@ "CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", "VCPKG_TARGET_TRIPLET": "x64-windows", "VCPKG_INSTALLED_DIR": "C:/vcpkg.installed", - "W3D_BUILD_QT_TOOLS": "ON" + "W3D_BUILD_QT_TOOLS": "ON", + "W3D_BUILD_OPTION_FFMPEG": "ON", + "W3D_BUILD_OPTION_OPENAL": "ON", + "W3D_BUILD_OPTION_MILES": "OFF" } }, { diff --git a/Code/Tools/CMakeLists.txt b/Code/Tools/CMakeLists.txt index ccebf4771..edade3389 100644 --- a/Code/Tools/CMakeLists.txt +++ b/Code/Tools/CMakeLists.txt @@ -14,4 +14,7 @@ if(W3D_BUILD_QT_TOOLS) add_subdirectory(WDumpCore) add_subdirectory(WWConfigQt) add_subdirectory(WDumpQt) + if(WIN32) + add_subdirectory(W3DViewQt) + endif() endif() diff --git a/Code/Tools/QtCommon/CMakeLists.txt b/Code/Tools/QtCommon/CMakeLists.txt index 775dc9e46..47b2d079c 100644 --- a/Code/Tools/QtCommon/CMakeLists.txt +++ b/Code/Tools/QtCommon/CMakeLists.txt @@ -1,6 +1,8 @@ set(QTCOMMON_SRC RecentFiles.cpp RecentFiles.h + ShortcutHelpers.cpp + ShortcutHelpers.h ) add_library(qtcommon STATIC) diff --git a/Code/Tools/QtCommon/ShortcutHelpers.cpp b/Code/Tools/QtCommon/ShortcutHelpers.cpp new file mode 100644 index 000000000..18b15ae8f --- /dev/null +++ b/Code/Tools/QtCommon/ShortcutHelpers.cpp @@ -0,0 +1,22 @@ +#include "ShortcutHelpers.h" + +#include +#include +#include + +namespace qtcommon { + +QAction *CreateWindowShortcutAction(QWidget *window, const QList &shortcuts) +{ + if (!window || shortcuts.isEmpty()) { + return nullptr; + } + + auto *action = new QAction(window); + action->setShortcuts(shortcuts); + action->setShortcutContext(Qt::WindowShortcut); + window->addAction(action); + return action; +} + +} // namespace qtcommon diff --git a/Code/Tools/QtCommon/ShortcutHelpers.h b/Code/Tools/QtCommon/ShortcutHelpers.h new file mode 100644 index 000000000..2a20c9af5 --- /dev/null +++ b/Code/Tools/QtCommon/ShortcutHelpers.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +class QAction; +class QKeySequence; +class QWidget; + +namespace qtcommon { + +QAction *CreateWindowShortcutAction(QWidget *window, const QList &shortcuts); + +} // namespace qtcommon diff --git a/Code/Tools/W3DViewQt/AddToLineupDialog.cpp b/Code/Tools/W3DViewQt/AddToLineupDialog.cpp new file mode 100644 index 000000000..a21e13b36 --- /dev/null +++ b/Code/Tools/W3DViewQt/AddToLineupDialog.cpp @@ -0,0 +1,75 @@ +#include "AddToLineupDialog.h" + +#include "W3DViewport.h" +#include "ui_AddToLineupDialog.h" + +#include "assetmgr.h" +#include "rendobj.h" + +#include +#include +#include + +AddToLineupDialog::AddToLineupDialog(W3DViewport *viewport, QWidget *parent) + : QDialog(parent) + , _viewport(viewport) + , _ui(new Ui::AddToLineupDialog) +{ + _ui->setupUi(this); + + connect(_ui->buttonBox, &QDialogButtonBox::accepted, this, &AddToLineupDialog::accept); + connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &AddToLineupDialog::reject); + + populateObjects(); +} + +AddToLineupDialog::~AddToLineupDialog() +{ + delete _ui; +} + +QString AddToLineupDialog::selectedName() const +{ + return _ui->objectComboBox->currentText().trimmed(); +} + +void AddToLineupDialog::accept() +{ + const QString name = selectedName(); + if (name.isEmpty()) { + QMessageBox::information(this, "Add To Lineup", "Please select an object or enter a name."); + return; + } + + QDialog::accept(); +} + +void AddToLineupDialog::populateObjects() +{ + if (!_viewport) { + return; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + return; + } + + RenderObjIterator *iterator = asset_manager->Create_Render_Obj_Iterator(); + if (!iterator) { + return; + } + + for (iterator->First(); !iterator->Is_Done(); iterator->Next()) { + const int class_id = iterator->Current_Item_Class_ID(); + if (!_viewport->canLineUpClass(class_id)) { + continue; + } + const char *name = iterator->Current_Item_Name(); + if (name && name[0]) { + _ui->objectComboBox->addItem(QString::fromLatin1(name)); + } + } + + asset_manager->Release_Render_Obj_Iterator(iterator); +} diff --git a/Code/Tools/W3DViewQt/AddToLineupDialog.h b/Code/Tools/W3DViewQt/AddToLineupDialog.h new file mode 100644 index 000000000..4c3108a07 --- /dev/null +++ b/Code/Tools/W3DViewQt/AddToLineupDialog.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +class W3DViewport; + +namespace Ui { +class AddToLineupDialog; +} + +class AddToLineupDialog final : public QDialog +{ + Q_OBJECT + +public: + explicit AddToLineupDialog(W3DViewport *viewport, QWidget *parent = nullptr); + ~AddToLineupDialog() override; + QString selectedName() const; + +protected: + void accept() override; + +private: + void populateObjects(); + + W3DViewport *_viewport = nullptr; + Ui::AddToLineupDialog *_ui = nullptr; +}; diff --git a/Code/Tools/W3DViewQt/AddToLineupDialog.ui b/Code/Tools/W3DViewQt/AddToLineupDialog.ui new file mode 100644 index 000000000..4fba52c2c --- /dev/null +++ b/Code/Tools/W3DViewQt/AddToLineupDialog.ui @@ -0,0 +1,41 @@ + + + AddToLineupDialog + + + Add To Lineup + + + + + + + + &Object: + + + objectComboBox + + + + + + + true + + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + diff --git a/Code/Tools/W3DViewQt/AdvancedAnimationDialog.cpp b/Code/Tools/W3DViewQt/AdvancedAnimationDialog.cpp new file mode 100644 index 000000000..0a7167672 --- /dev/null +++ b/Code/Tools/W3DViewQt/AdvancedAnimationDialog.cpp @@ -0,0 +1,326 @@ +#include "AdvancedAnimationDialog.h" + +#include "W3DViewport.h" +#include "ui_AdvancedAnimationDialog.h" + +#include "assetmgr.h" +#include "hanim.h" +#include "htree.h" +#include "rendobj.h" + +#include +#include +#include +#include +#include +#include + +namespace { +constexpr int kMaxReportAnims = 128; + +QString AnimName(HAnimClass *anim) +{ + return anim && anim->Get_Name() ? QString::fromLatin1(anim->Get_Name()) : QString(); +} +} + +AdvancedAnimationDialog::AdvancedAnimationDialog(W3DViewport *viewport, + const QString &renderObjectName, + QWidget *parent) + : QDialog(parent) + , _ui(new Ui::AdvancedAnimationDialog) + , _viewport(viewport) + , _renderObjectName(renderObjectName) +{ + _ui->setupUi(this); + + connect(_ui->buttonBox, &QDialogButtonBox::accepted, + this, &AdvancedAnimationDialog::accept); + connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + connect(_ui->tabWidget, &QTabWidget::currentChanged, + this, &AdvancedAnimationDialog::onTabChanged); + connect(_ui->mixingListWidget, &QListWidget::itemSelectionChanged, + this, &AdvancedAnimationDialog::updateReport); + + loadAnimations(); + populateMixingList(); + updateReport(); +} + +AdvancedAnimationDialog::~AdvancedAnimationDialog() +{ + for (auto *anim : _animations) { + if (anim) { + anim->Release_Ref(); + } + } + delete _ui; +} + +void AdvancedAnimationDialog::accept() +{ + if (!_viewport || _renderObjectName.isEmpty()) { + QDialog::accept(); + return; + } + + QVector selected_indices; + const QList selected_items = _ui->mixingListWidget->selectedItems(); + if (selected_items.isEmpty()) { + QDialog::accept(); + return; + } + selected_indices.reserve(selected_items.size()); + for (auto *item : selected_items) { + const int row = _ui->mixingListWidget->row(item); + if (row >= 0 && row < _animations.size()) { + selected_indices.append(row); + } + } + if (selected_indices.isEmpty()) { + QDialog::accept(); + return; + } + std::sort(selected_indices.begin(), selected_indices.end()); + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + QMessageBox::warning(this, "Advanced Animation", "WW3D asset manager is not available."); + return; + } + + const QByteArray name_bytes = _renderObjectName.toLatin1(); + RenderObjClass *render_obj = asset_manager->Create_Render_Obj(name_bytes.constData()); + if (!render_obj) { + QMessageBox::warning(this, "Advanced Animation", "Failed to load render object."); + return; + } + + const int count = selected_indices.size(); + auto *combo = new HAnimComboClass(count); + int idx = 0; + for (int anim_index : selected_indices) { + HAnimClass *anim = _animations[anim_index]; + combo->Set_Motion(idx, anim); + combo->Set_Weight(idx, 1.0f); + if (auto *combo_data = combo->Peek_Anim_Combo_Data(idx)) { + combo_data->Build_Active_Pivot_Map(); + } + ++idx; + } + + _viewport->clearAnimation(); + _viewport->setRenderObject(render_obj); + _viewport->setAnimationCombo(combo); + render_obj->Release_Ref(); + + QDialog::accept(); +} + +void AdvancedAnimationDialog::updateReport() +{ + _ui->reportTableWidget->clear(); + _ui->reportTableWidget->setRowCount(0); + _ui->reportTableWidget->setColumnCount(0); + + if (!_hasHierarchy || _animations.isEmpty()) { + _ui->reportTableWidget->setRowCount(0); + _ui->reportTableWidget->setColumnCount(0); + return; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + return; + } + + const QByteArray name_bytes = _renderObjectName.toLatin1(); + RenderObjClass *render_obj = asset_manager->Create_Render_Obj(name_bytes.constData()); + if (!render_obj) { + return; + } + + const HTreeClass *htree = render_obj->Get_HTree(); + if (!htree) { + render_obj->Release_Ref(); + return; + } + + QVector anim_indices; + const QList selected_items = _ui->mixingListWidget->selectedItems(); + if (!selected_items.isEmpty()) { + for (auto *item : selected_items) { + const int row = _ui->mixingListWidget->row(item); + if (row >= 0 && row < _animations.size()) { + anim_indices.append(row); + } + } + std::sort(anim_indices.begin(), anim_indices.end()); + } else { + anim_indices.reserve(_animations.size()); + for (int i = 0; i < _animations.size(); ++i) { + anim_indices.append(i); + } + } + + const int column_count = anim_indices.size() + 1; + _ui->reportTableWidget->setColumnCount(column_count); + QStringList headers; + headers << "Bone Name"; + for (int index : anim_indices) { + headers << AnimName(_animations[index]); + } + _ui->reportTableWidget->setHorizontalHeaderLabels(headers); + + const int bone_count = render_obj->Get_Num_Bones(); + int row = 0; + for (int bone_index = 1; bone_index < bone_count; ++bone_index) { + const char *bone_name = htree->Get_Bone_Name(bone_index); + if (!bone_name) { + continue; + } + + _ui->reportTableWidget->insertRow(row); + auto *bone_item = new QTableWidgetItem(QString::fromLatin1(bone_name)); + _ui->reportTableWidget->setItem(row, 0, bone_item); + + for (int column = 0; column < anim_indices.size(); ++column) { + const int anim_index = anim_indices[column]; + HAnimClass *anim = _animations[anim_index]; + if (anim && anim->Is_Node_Motion_Present(bone_index)) { + const QString channels = makeChannelString(bone_index, anim); + auto *cell = new QTableWidgetItem(channels); + _ui->reportTableWidget->setItem(row, column + 1, cell); + } + } + ++row; + } + + _ui->reportTableWidget->resizeColumnsToContents(); + render_obj->Release_Ref(); +} + +void AdvancedAnimationDialog::onTabChanged(int index) +{ + Q_UNUSED(index); + updateReport(); +} + +void AdvancedAnimationDialog::loadAnimations() +{ + _animations.clear(); + _hasHierarchy = false; + _hierarchyName.clear(); + + if (_renderObjectName.isEmpty()) { + return; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + return; + } + + const QByteArray name_bytes = _renderObjectName.toLatin1(); + RenderObjClass *render_obj = asset_manager->Create_Render_Obj(name_bytes.constData()); + if (!render_obj) { + return; + } + + const HTreeClass *htree = render_obj->Get_HTree(); + if (!htree || !htree->Get_Name()) { + render_obj->Release_Ref(); + return; + } + + _hasHierarchy = true; + _hierarchyName = QString::fromLatin1(htree->Get_Name()); + render_obj->Release_Ref(); + + AssetIterator *iterator = asset_manager->Create_HAnim_Iterator(); + if (!iterator) { + return; + } + + for (iterator->First(); !iterator->Is_Done(); iterator->Next()) { + const char *anim_name = iterator->Current_Item_Name(); + if (!anim_name || !anim_name[0]) { + continue; + } + + HAnimClass *anim = asset_manager->Get_HAnim(anim_name); + if (!anim) { + continue; + } + + const char *hname = anim->Get_HName(); + if (hname && _hierarchyName.compare(QString::fromLatin1(hname), Qt::CaseInsensitive) == 0) { + _animations.append(anim); + if (_animations.size() >= kMaxReportAnims) { + QMessageBox::warning(this, + "Advanced Animation", + QString("Only %1 animations are supported in this report." + " More are loaded and will be ignored.") + .arg(kMaxReportAnims)); + break; + } + continue; + } + + anim->Release_Ref(); + } + + delete iterator; + + std::sort(_animations.begin(), _animations.end(), [](HAnimClass *a, HAnimClass *b) { + return QString::compare(AnimName(a), AnimName(b), Qt::CaseInsensitive) < 0; + }); +} + +void AdvancedAnimationDialog::populateMixingList() +{ + _ui->mixingListWidget->clear(); + + if (!_hasHierarchy) { + _ui->mixingListWidget->addItem("No hierarchy available for this object."); + _ui->mixingListWidget->setEnabled(false); + return; + } + + if (_animations.isEmpty()) { + _ui->mixingListWidget->addItem("No animations available."); + _ui->mixingListWidget->setEnabled(false); + return; + } + + _ui->mixingListWidget->setEnabled(true); + for (auto *anim : _animations) { + _ui->mixingListWidget->addItem(AnimName(anim)); + } +} + +QString AdvancedAnimationDialog::makeChannelString(int boneIndex, HAnimClass *anim) const +{ + QString channels; + if (!anim) { + return channels; + } + + if (anim->Has_X_Translation(boneIndex)) { + channels += 'X'; + } + if (anim->Has_Y_Translation(boneIndex)) { + channels += 'Y'; + } + if (anim->Has_Z_Translation(boneIndex)) { + channels += 'Z'; + } + if (anim->Has_Rotation(boneIndex)) { + channels += 'Q'; + } + if (anim->Has_Visibility(boneIndex)) { + channels += 'V'; + } + + return channels; +} diff --git a/Code/Tools/W3DViewQt/AdvancedAnimationDialog.h b/Code/Tools/W3DViewQt/AdvancedAnimationDialog.h new file mode 100644 index 000000000..8139fd6a5 --- /dev/null +++ b/Code/Tools/W3DViewQt/AdvancedAnimationDialog.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include + +class HAnimClass; +class W3DViewport; + +namespace Ui { +class AdvancedAnimationDialog; +} + +class AdvancedAnimationDialog final : public QDialog +{ + Q_OBJECT + +public: + explicit AdvancedAnimationDialog(W3DViewport *viewport, + const QString &renderObjectName, + QWidget *parent = nullptr); + ~AdvancedAnimationDialog() override; + +protected: + void accept() override; + +private slots: + void updateReport(); + void onTabChanged(int index); + +private: + void loadAnimations(); + void populateMixingList(); + QString makeChannelString(int boneIndex, HAnimClass *anim) const; + + Ui::AdvancedAnimationDialog *_ui = nullptr; + W3DViewport *_viewport = nullptr; + QString _renderObjectName; + QVector _animations; + bool _hasHierarchy = false; + QString _hierarchyName; +}; diff --git a/Code/Tools/W3DViewQt/AdvancedAnimationDialog.ui b/Code/Tools/W3DViewQt/AdvancedAnimationDialog.ui new file mode 100644 index 000000000..4b65cc40c --- /dev/null +++ b/Code/Tools/W3DViewQt/AdvancedAnimationDialog.ui @@ -0,0 +1,65 @@ + + + AdvancedAnimationDialog + + + Advanced Animation + + + + + + + Mixing + + + + + + Select animations to mix: + + + + + + + QAbstractItemView::ExtendedSelection + + + + + + + + Report + + + + + + QAbstractItemView::NoEditTriggers + + + QAbstractItemView::NoSelection + + + false + + + + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + diff --git a/Code/Tools/W3DViewQt/AggregateNameDialog.cpp b/Code/Tools/W3DViewQt/AggregateNameDialog.cpp new file mode 100644 index 000000000..92ffda7d3 --- /dev/null +++ b/Code/Tools/W3DViewQt/AggregateNameDialog.cpp @@ -0,0 +1,32 @@ +#include "AggregateNameDialog.h" + +#include "ui_AggregateNameDialog.h" + +#include "w3d_file.h" + +#include + +AggregateNameDialog::AggregateNameDialog(const QString &title, + const QString &defaultName, + QWidget *parent) + : QDialog(parent) + , _ui(new Ui::AggregateNameDialog) +{ + _ui->setupUi(this); + setWindowTitle(title); + _ui->nameLineEdit->setMaxLength(W3D_NAME_LEN - 1); + _ui->nameLineEdit->setText(defaultName); + + connect(_ui->buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); +} + +AggregateNameDialog::~AggregateNameDialog() +{ + delete _ui; +} + +QString AggregateNameDialog::name() const +{ + return _ui->nameLineEdit->text(); +} diff --git a/Code/Tools/W3DViewQt/AggregateNameDialog.h b/Code/Tools/W3DViewQt/AggregateNameDialog.h new file mode 100644 index 000000000..ebea8ae5d --- /dev/null +++ b/Code/Tools/W3DViewQt/AggregateNameDialog.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +namespace Ui { +class AggregateNameDialog; +} + +class AggregateNameDialog final : public QDialog +{ + Q_OBJECT + +public: + explicit AggregateNameDialog(const QString &title, + const QString &defaultName = QString(), + QWidget *parent = nullptr); + ~AggregateNameDialog() override; + + QString name() const; + +private: + Ui::AggregateNameDialog *_ui = nullptr; +}; diff --git a/Code/Tools/W3DViewQt/AggregateNameDialog.ui b/Code/Tools/W3DViewQt/AggregateNameDialog.ui new file mode 100644 index 000000000..2b3169168 --- /dev/null +++ b/Code/Tools/W3DViewQt/AggregateNameDialog.ui @@ -0,0 +1,37 @@ + + + AggregateNameDialog + + + Aggregate Name + + + + + + + + &Name: + + + nameLineEdit + + + + + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + diff --git a/Code/Tools/W3DViewQt/AnimatedSoundOptionsDialog.cpp b/Code/Tools/W3DViewQt/AnimatedSoundOptionsDialog.cpp new file mode 100644 index 000000000..fedf08589 --- /dev/null +++ b/Code/Tools/W3DViewQt/AnimatedSoundOptionsDialog.cpp @@ -0,0 +1,159 @@ +#include "AnimatedSoundOptionsDialog.h" + +#include "ui_AnimatedSoundOptionsDialog.h" + +#include "animatedsoundmgr.h" +#include "chunkio.h" +#include "definitionmgr.h" +#include "ffactory.h" +#include "wwdebug.h" +#include "wwfile.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { +QString NormalizePath(const QString &path) +{ + if (path.trimmed().isEmpty()) { + return QString(); + } + + return QDir::cleanPath(path.trimmed()); +} + +QString StartDirectoryForFile(const QString &path) +{ + if (path.trimmed().isEmpty()) { + return QDir::currentPath(); + } + + const QFileInfo info(path); + if (info.exists()) { + return info.absolutePath(); + } + + return QFileInfo(path).absolutePath(); +} +} + +AnimatedSoundOptionsDialog::AnimatedSoundOptionsDialog(const QString &definitionLibraryPath, + const QString &iniPath, + const QString &dataPath, + QWidget *parent) + : QDialog(parent) + , _ui(new Ui::AnimatedSoundOptionsDialog) +{ + _ui->setupUi(this); + _ui->definitionLibraryEdit->setText(QDir::toNativeSeparators(definitionLibraryPath)); + _ui->iniEdit->setText(QDir::toNativeSeparators(iniPath)); + _ui->dataPathEdit->setText(QDir::toNativeSeparators(dataPath)); + + connect(_ui->definitionBrowseButton, &QPushButton::clicked, this, + &AnimatedSoundOptionsDialog::browseDefinitionLibrary); + connect(_ui->iniBrowseButton, &QPushButton::clicked, this, + &AnimatedSoundOptionsDialog::browseIniPath); + connect(_ui->dataBrowseButton, &QPushButton::clicked, this, + &AnimatedSoundOptionsDialog::browseDataPath); + connect(_ui->buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); +} + +AnimatedSoundOptionsDialog::~AnimatedSoundOptionsDialog() +{ + delete _ui; +} + +QString AnimatedSoundOptionsDialog::definitionLibraryPath() const +{ + return _ui->definitionLibraryEdit->text().trimmed(); +} + +QString AnimatedSoundOptionsDialog::iniPath() const +{ + return _ui->iniEdit->text().trimmed(); +} + +QString AnimatedSoundOptionsDialog::dataPath() const +{ + return _ui->dataPathEdit->text().trimmed(); +} + +void AnimatedSoundOptionsDialog::LoadAnimatedSoundSettings() +{ + DefinitionMgrClass::Free_Definitions(); + + QSettings settings; + const QString definition_path = NormalizePath(settings.value("Config/SoundDefLibPath").toString()); + const QString ini_path = NormalizePath(settings.value("Config/AnimSoundINIPath").toString()); + const QString data_path = NormalizePath(settings.value("Config/AnimSoundDataPath").toString()); + + if (_TheFileFactory && !definition_path.isEmpty()) { + const QByteArray native = QDir::toNativeSeparators(definition_path).toLocal8Bit(); + FileClass *file = _TheFileFactory->Get_File(native.constData()); + if (file != nullptr) { + file->Open(FileClass::READ); + ChunkLoadClass cload(file); + SaveLoadSystemClass::Load(cload); + file->Close(); + _TheFileFactory->Return_File(file); + } else { + WWDEBUG_SAY(("Failed to load file %s\n", native.constData())); + } + } + + AnimatedSoundMgrClass::Shutdown(); + if (ini_path.isEmpty()) { + AnimatedSoundMgrClass::Initialize(""); + } else { + const QByteArray native = QDir::toNativeSeparators(ini_path).toLocal8Bit(); + AnimatedSoundMgrClass::Initialize(native.constData()); + } + + if (_TheSimpleFileFactory && !data_path.isEmpty()) { + const QByteArray native = QDir::toNativeSeparators(data_path).toLocal8Bit(); + _TheSimpleFileFactory->Append_Sub_Directory(native.constData()); + } +} + +void AnimatedSoundOptionsDialog::browseDefinitionLibrary() +{ + const QString start = _ui->definitionLibraryEdit->text(); + const QString initial_dir = StartDirectoryForFile(start); + const QString path = QFileDialog::getOpenFileName( + this, + "Sound Preset Library", + initial_dir, + "Definition Database Files (*.ddb)"); + if (!path.isEmpty()) { + _ui->definitionLibraryEdit->setText(QDir::toNativeSeparators(path)); + } +} + +void AnimatedSoundOptionsDialog::browseIniPath() +{ + const QString start = _ui->iniEdit->text(); + const QString initial_dir = StartDirectoryForFile(start); + const QString path = QFileDialog::getOpenFileName( + this, + "Animated Sound INI", + initial_dir, + "INI Files (*.ini)"); + if (!path.isEmpty()) { + _ui->iniEdit->setText(QDir::toNativeSeparators(path)); + } +} + +void AnimatedSoundOptionsDialog::browseDataPath() +{ + const QString start = _ui->dataPathEdit->text(); + const QString dir = QFileDialog::getExistingDirectory(this, "Pick Sound Path", start); + if (!dir.isEmpty()) { + _ui->dataPathEdit->setText(QDir::toNativeSeparators(dir)); + } +} diff --git a/Code/Tools/W3DViewQt/AnimatedSoundOptionsDialog.h b/Code/Tools/W3DViewQt/AnimatedSoundOptionsDialog.h new file mode 100644 index 000000000..47e5f1134 --- /dev/null +++ b/Code/Tools/W3DViewQt/AnimatedSoundOptionsDialog.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include + +namespace Ui { +class AnimatedSoundOptionsDialog; +} + +class AnimatedSoundOptionsDialog final : public QDialog +{ + Q_OBJECT + +public: + explicit AnimatedSoundOptionsDialog(const QString &definitionLibraryPath, + const QString &iniPath, + const QString &dataPath, + QWidget *parent = nullptr); + ~AnimatedSoundOptionsDialog() override; + + QString definitionLibraryPath() const; + QString iniPath() const; + QString dataPath() const; + + static void LoadAnimatedSoundSettings(); + +private slots: + void browseDefinitionLibrary(); + void browseIniPath(); + void browseDataPath(); + +private: + Ui::AnimatedSoundOptionsDialog *_ui = nullptr; +}; diff --git a/Code/Tools/W3DViewQt/AnimatedSoundOptionsDialog.ui b/Code/Tools/W3DViewQt/AnimatedSoundOptionsDialog.ui new file mode 100644 index 000000000..01f8d0d5d --- /dev/null +++ b/Code/Tools/W3DViewQt/AnimatedSoundOptionsDialog.ui @@ -0,0 +1,106 @@ + + + AnimatedSoundOptionsDialog + + + Animated Sound Options + + + + + + Use the controls below to configure the animation-triggered sound effect settings for the viewer. + + + true + + + + + + + + + Sound Preset Library Path: + + + definitionLibraryEdit + + + + + + + + + + + + Browse... + + + + + + + + + Animated Sound INI Path: + + + iniEdit + + + + + + + + + + + + Browse... + + + + + + + + + Sound File(s) Path: + + + dataPathEdit + + + + + + + + + + + + Browse... + + + + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + diff --git a/Code/Tools/W3DViewQt/AnimationPropertiesDialog.cpp b/Code/Tools/W3DViewQt/AnimationPropertiesDialog.cpp new file mode 100644 index 000000000..e32f3c8c2 --- /dev/null +++ b/Code/Tools/W3DViewQt/AnimationPropertiesDialog.cpp @@ -0,0 +1,63 @@ +#include "AnimationPropertiesDialog.h" + +#include "ui_AnimationPropertiesDialog.h" + +#include "assetmgr.h" +#include "hanim.h" + +#include + +AnimationPropertiesDialog::AnimationPropertiesDialog(const QString &animationName, QWidget *parent) + : QDialog(parent) + , _ui(new Ui::AnimationPropertiesDialog) +{ + _ui->setupUi(this); + connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + + if (animationName.isEmpty()) { + setErrorState("No animation selected."); + return; + } + + _ui->descriptionLabel->setText(QString("Animation: %1").arg(animationName)); + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + setErrorState("WW3D asset manager is not available."); + return; + } + + const QByteArray name_bytes = animationName.toLatin1(); + HAnimClass *animation = asset_manager->Get_HAnim(name_bytes.constData()); + if (!animation) { + setErrorState("Failed to load animation."); + return; + } + + _ui->frameCountValue->setText(QString::number(animation->Get_Num_Frames())); + _ui->frameRateValue->setText(QString("%1 fps").arg(animation->Get_Frame_Rate(), 0, 'f', 2)); + _ui->totalTimeValue->setText(QString("%1 seconds").arg(animation->Get_Total_Time(), 0, 'f', 3)); + + const char *hier_name = animation->Get_HName(); + if (hier_name) { + _ui->hierarchyNameValue->setText(QString::fromLatin1(hier_name)); + } else { + _ui->hierarchyNameValue->setText(""); + } + + animation->Release_Ref(); +} + +AnimationPropertiesDialog::~AnimationPropertiesDialog() +{ + delete _ui; +} + +void AnimationPropertiesDialog::setErrorState(const QString &message) +{ + _ui->descriptionLabel->setText(message); + _ui->frameCountValue->setText("n/a"); + _ui->frameRateValue->setText("n/a"); + _ui->totalTimeValue->setText("n/a"); + _ui->hierarchyNameValue->setText("n/a"); +} diff --git a/Code/Tools/W3DViewQt/AnimationPropertiesDialog.h b/Code/Tools/W3DViewQt/AnimationPropertiesDialog.h new file mode 100644 index 000000000..96cb3a3db --- /dev/null +++ b/Code/Tools/W3DViewQt/AnimationPropertiesDialog.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +namespace Ui { +class AnimationPropertiesDialog; +} + +class AnimationPropertiesDialog final : public QDialog +{ + Q_OBJECT + +public: + explicit AnimationPropertiesDialog(const QString &animationName, QWidget *parent = nullptr); + ~AnimationPropertiesDialog() override; + +private: + void setErrorState(const QString &message); + + Ui::AnimationPropertiesDialog *_ui = nullptr; +}; diff --git a/Code/Tools/W3DViewQt/AnimationPropertiesDialog.ui b/Code/Tools/W3DViewQt/AnimationPropertiesDialog.ui new file mode 100644 index 000000000..6d6029605 --- /dev/null +++ b/Code/Tools/W3DViewQt/AnimationPropertiesDialog.ui @@ -0,0 +1,105 @@ + + + AnimationPropertiesDialog + + + Animation Properties + + + + + + + + + Qt::TextSelectableByMouse + + + true + + + + + + + + + Frame Count: + + + + + + + n/a + + + Qt::TextSelectableByMouse + + + + + + + Frame Rate: + + + + + + + n/a + + + Qt::TextSelectableByMouse + + + + + + + Total Time: + + + + + + + n/a + + + Qt::TextSelectableByMouse + + + + + + + Hierarchy: + + + + + + + n/a + + + Qt::TextSelectableByMouse + + + + + + + + + QDialogButtonBox::Close + + + + + + + + diff --git a/Code/Tools/W3DViewQt/AnimationSettingsDialog.cpp b/Code/Tools/W3DViewQt/AnimationSettingsDialog.cpp new file mode 100644 index 000000000..41a9d1ce6 --- /dev/null +++ b/Code/Tools/W3DViewQt/AnimationSettingsDialog.cpp @@ -0,0 +1,45 @@ +#include "AnimationSettingsDialog.h" + +#include "W3DViewport.h" +#include "ui_AnimationSettingsDialog.h" + +#include +#include +#include +#include + +#include + +AnimationSettingsDialog::AnimationSettingsDialog(W3DViewport &viewport, QWidget *parent) + : QDialog(parent) + , _ui(new Ui::AnimationSettingsDialog) + , _viewport(&viewport) +{ + _ui->setupUi(this); + + const int initial_percent = + std::clamp(static_cast(viewport.animationSpeed() * 100.0f + 0.5f), 1, 200); + _ui->speedSlider->setValue(initial_percent); + _ui->blendCheckBox->setChecked(_viewport->animationBlend()); + updateSpeed(initial_percent); + + connect(_ui->speedSlider, &QSlider::valueChanged, this, [this](int percent) { + updateSpeed(percent); + _viewport->setAnimationSpeed(static_cast(percent) / 100.0f); + }); + connect(_ui->blendCheckBox, &QCheckBox::toggled, + _viewport, &W3DViewport::setAnimationBlend); + connect(_ui->buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); +} + +AnimationSettingsDialog::~AnimationSettingsDialog() +{ + delete _ui; +} + +void AnimationSettingsDialog::updateSpeed(int percent) +{ + const float speed = static_cast(percent) / 100.0f; + _ui->speedValueLabel->setText(QString("Speed: %1x").arg(speed, 0, 'f', 2)); +} diff --git a/Code/Tools/W3DViewQt/AnimationSettingsDialog.h b/Code/Tools/W3DViewQt/AnimationSettingsDialog.h new file mode 100644 index 000000000..7d5474248 --- /dev/null +++ b/Code/Tools/W3DViewQt/AnimationSettingsDialog.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +class W3DViewport; + +namespace Ui { +class AnimationSettingsDialog; +} + +class AnimationSettingsDialog final : public QDialog +{ + Q_OBJECT + +public: + explicit AnimationSettingsDialog(W3DViewport &viewport, QWidget *parent = nullptr); + ~AnimationSettingsDialog() override; + +private: + void updateSpeed(int percent); + + Ui::AnimationSettingsDialog *_ui = nullptr; + W3DViewport *_viewport = nullptr; +}; diff --git a/Code/Tools/W3DViewQt/AnimationSettingsDialog.ui b/Code/Tools/W3DViewQt/AnimationSettingsDialog.ui new file mode 100644 index 000000000..cd27f4548 --- /dev/null +++ b/Code/Tools/W3DViewQt/AnimationSettingsDialog.ui @@ -0,0 +1,60 @@ + + + AnimationSettingsDialog + + + Animation Settings + + + + + + Select the rate at which you want to display the animation. + + + true + + + + + + + Speed: 1.00x + + + + + + + 1 + + + 200 + + + 100 + + + Qt::Horizontal + + + + + + + &Blend frames + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + diff --git a/Code/Tools/W3DViewQt/BackgroundBitmapDialog.cpp b/Code/Tools/W3DViewQt/BackgroundBitmapDialog.cpp new file mode 100644 index 000000000..60677b5a3 --- /dev/null +++ b/Code/Tools/W3DViewQt/BackgroundBitmapDialog.cpp @@ -0,0 +1,56 @@ +#include "BackgroundBitmapDialog.h" + +#include "ui_BackgroundBitmapDialog.h" + +#include +#include +#include +#include +#include + +BackgroundBitmapDialog::BackgroundBitmapDialog(const QString ¤tPath, QWidget *parent) + : QDialog(parent) + , _ui(new Ui::BackgroundBitmapDialog) +{ + _ui->setupUi(this); + _ui->pathLineEdit->setText(QDir::toNativeSeparators(currentPath)); + + connect(_ui->browseButton, &QPushButton::clicked, this, &BackgroundBitmapDialog::browse); + connect(_ui->clearButton, &QPushButton::clicked, this, + &BackgroundBitmapDialog::clearPath); + connect(_ui->buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); +} + +BackgroundBitmapDialog::~BackgroundBitmapDialog() +{ + delete _ui; +} + +QString BackgroundBitmapDialog::selectedPath() const +{ + return _ui->pathLineEdit->text().trimmed(); +} + +void BackgroundBitmapDialog::browse() +{ + QString startPath = selectedPath(); + if (startPath.isEmpty()) { + startPath = QDir::currentPath(); + } + + const QString path = QFileDialog::getOpenFileName( + this, + "Background Bitmap", + startPath, + "Images (*.bmp *.tga *.dds);;Targa Images (*.tga);;All Files (*.*)"); + if (!path.isEmpty()) { + _ui->pathLineEdit->setText(QDir::toNativeSeparators(path)); + } +} + +void BackgroundBitmapDialog::clearPath() +{ + _ui->pathLineEdit->clear(); + _ui->pathLineEdit->setFocus(); +} diff --git a/Code/Tools/W3DViewQt/BackgroundBitmapDialog.h b/Code/Tools/W3DViewQt/BackgroundBitmapDialog.h new file mode 100644 index 000000000..1df442d1c --- /dev/null +++ b/Code/Tools/W3DViewQt/BackgroundBitmapDialog.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +namespace Ui { +class BackgroundBitmapDialog; +} + +class BackgroundBitmapDialog final : public QDialog +{ + Q_OBJECT + +public: + explicit BackgroundBitmapDialog(const QString ¤tPath, QWidget *parent = nullptr); + ~BackgroundBitmapDialog() override; + + QString selectedPath() const; + +private slots: + void browse(); + void clearPath(); + +private: + Ui::BackgroundBitmapDialog *_ui = nullptr; +}; diff --git a/Code/Tools/W3DViewQt/BackgroundBitmapDialog.ui b/Code/Tools/W3DViewQt/BackgroundBitmapDialog.ui new file mode 100644 index 000000000..dfd9a6949 --- /dev/null +++ b/Code/Tools/W3DViewQt/BackgroundBitmapDialog.ui @@ -0,0 +1,80 @@ + + + BackgroundBitmapDialog + + + Background Bitmap + + + + + + Enter an image filename to use as a backdrop for the object view. Leave the filename empty to clear the current backdrop. + + + true + + + + + + + + + &Filename: + + + pathLineEdit + + + + + + + + 300 + 0 + + + + true + + + + + + + &Browse... + + + + + + + C&lear + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + pathLineEdit + browseButton + clearButton + buttonBox + + + + diff --git a/Code/Tools/W3DViewQt/BackgroundObjectDialog.cpp b/Code/Tools/W3DViewQt/BackgroundObjectDialog.cpp new file mode 100644 index 000000000..11622714e --- /dev/null +++ b/Code/Tools/W3DViewQt/BackgroundObjectDialog.cpp @@ -0,0 +1,106 @@ +#include "BackgroundObjectDialog.h" + +#include "ui_BackgroundObjectDialog.h" + +#include "assetmgr.h" +#include "rendobj.h" + +#include +#include +#include + +#include + +BackgroundObjectDialog::BackgroundObjectDialog(const QString ¤tName, QWidget *parent) + : QDialog(parent) + , _ui(new Ui::BackgroundObjectDialog) +{ + _ui->setupUi(this); + _ui->buttonBox->addButton(_ui->clearButton, QDialogButtonBox::ResetRole); + + connect(_ui->listWidget, &QListWidget::itemSelectionChanged, this, + &BackgroundObjectDialog::onSelectionChanged); + connect(_ui->clearButton, &QPushButton::clicked, this, &BackgroundObjectDialog::onClear); + connect(_ui->buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + return; + } + + RenderObjIterator *iterator = asset_manager->Create_Render_Obj_Iterator(); + if (!iterator) { + return; + } + + QStringList object_names; + for (iterator->First(); !iterator->Is_Done(); iterator->Next()) { + const char *name = iterator->Current_Item_Name(); + if (!name || !name[0]) { + continue; + } + + if (!asset_manager->Render_Obj_Exists(name)) { + continue; + } + + if (iterator->Current_Item_Class_ID() != RenderObjClass::CLASSID_HMODEL) { + continue; + } + + object_names.push_back(QString::fromLatin1(name)); + } + + asset_manager->Release_Render_Obj_Iterator(iterator); + + std::sort(object_names.begin(), object_names.end(), [](const QString &left, + const QString &right) { + const int case_insensitive = QString::compare(left, right, Qt::CaseInsensitive); + if (case_insensitive != 0) { + return case_insensitive < 0; + } + + return QString::compare(left, right, Qt::CaseSensitive) < 0; + }); + _ui->listWidget->addItems(object_names); + + if (!currentName.isEmpty()) { + const QList matches = + _ui->listWidget->findItems(currentName, Qt::MatchFixedString); + if (!matches.isEmpty()) { + _ui->listWidget->setCurrentItem(matches.front()); + } + } + + if (!_ui->listWidget->currentItem() && _ui->listWidget->count() > 0) { + _ui->listWidget->setCurrentRow(0); + } + + onSelectionChanged(); +} + +BackgroundObjectDialog::~BackgroundObjectDialog() +{ + delete _ui; +} + +QString BackgroundObjectDialog::selectedName() const +{ + const QList selected_items = _ui->listWidget->selectedItems(); + return selected_items.isEmpty() ? QString() : selected_items.front()->text(); +} + +void BackgroundObjectDialog::onSelectionChanged() +{ + const QString name = selectedName(); + _ui->currentLabel->setText(name.isEmpty() ? "Current Object: (none)" + : QString("Current Object: %1").arg(name)); +} + +void BackgroundObjectDialog::onClear() +{ + _ui->listWidget->clearSelection(); + _ui->listWidget->setCurrentRow(-1); + onSelectionChanged(); +} diff --git a/Code/Tools/W3DViewQt/BackgroundObjectDialog.h b/Code/Tools/W3DViewQt/BackgroundObjectDialog.h new file mode 100644 index 000000000..764a9bb76 --- /dev/null +++ b/Code/Tools/W3DViewQt/BackgroundObjectDialog.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +namespace Ui { +class BackgroundObjectDialog; +} + +class BackgroundObjectDialog final : public QDialog +{ + Q_OBJECT + +public: + explicit BackgroundObjectDialog(const QString ¤tName, QWidget *parent = nullptr); + ~BackgroundObjectDialog() override; + + QString selectedName() const; + +private slots: + void onSelectionChanged(); + void onClear(); + +private: + Ui::BackgroundObjectDialog *_ui = nullptr; +}; diff --git a/Code/Tools/W3DViewQt/BackgroundObjectDialog.ui b/Code/Tools/W3DViewQt/BackgroundObjectDialog.ui new file mode 100644 index 000000000..83f76150c --- /dev/null +++ b/Code/Tools/W3DViewQt/BackgroundObjectDialog.ui @@ -0,0 +1,39 @@ + + + BackgroundObjectDialog + + + Background Object + + + + + + + + + + + + + QAbstractItemView::SingleSelection + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + Clear + + + + + + + + + diff --git a/Code/Tools/W3DViewQt/BoneManagementDialog.cpp b/Code/Tools/W3DViewQt/BoneManagementDialog.cpp new file mode 100644 index 000000000..8d9c6c99e --- /dev/null +++ b/Code/Tools/W3DViewQt/BoneManagementDialog.cpp @@ -0,0 +1,364 @@ +#include "BoneManagementDialog.h" + +#include "ui_BoneManagementDialog.h" + +#include "RenderObjUtils.h" +#include "W3DViewport.h" + +#include "assetmgr.h" +#include "rendobj.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +QString NormalizeName(const char *name) +{ + return name ? QString::fromLatin1(name) : QString(); +} +} + +BoneManagementDialog::BoneManagementDialog(RenderObjClass *baseModel, + W3DViewport *viewport, + QWidget *parent) + : QDialog(parent) + , _baseModel(baseModel) + , _viewport(viewport) + , _ui(new Ui::BoneManagementDialog) +{ + _ui->setupUi(this); + + if (_baseModel) { + _baseModel->Add_Ref(); + _backupModel = _baseModel->Clone(); + } + + connect(_ui->buttonBox, &QDialogButtonBox::accepted, this, &BoneManagementDialog::accept); + connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &BoneManagementDialog::reject); + + connect(_ui->boneTree, + &QTreeWidget::currentItemChanged, + this, + &BoneManagementDialog::onBoneSelectionChanged); + connect(_ui->objectCombo, + qOverload(&QComboBox::currentIndexChanged), + this, + &BoneManagementDialog::onObjectSelectionChanged); + connect(_ui->attachButton, &QPushButton::clicked, this, &BoneManagementDialog::onAttachClicked); + + populateBones(); + populateObjectList(); + + if (_ui->boneTree->topLevelItemCount() > 0) { + _ui->boneTree->setCurrentItem(_ui->boneTree->topLevelItem(0)); + } else { + updateControls(nullptr); + } +} + +BoneManagementDialog::~BoneManagementDialog() +{ + if (_baseModel) { + _baseModel->Release_Ref(); + } + if (_backupModel) { + _backupModel->Release_Ref(); + } + + delete _ui; +} + +void BoneManagementDialog::accept() +{ + if (_baseModel) { + UpdateAggregatePrototype(*_baseModel); + } + + QDialog::accept(); +} + +void BoneManagementDialog::reject() +{ + if (_backupModel && _viewport) { + _viewport->clearAnimation(); + _viewport->setRenderObject(_backupModel); + } + + QDialog::reject(); +} + +void BoneManagementDialog::onBoneSelectionChanged(QTreeWidgetItem *current, QTreeWidgetItem *previous) +{ + Q_UNUSED(previous); + updateControls(current); +} + +void BoneManagementDialog::onObjectSelectionChanged(int index) +{ + Q_UNUSED(index); + updateAttachButton(); +} + +void BoneManagementDialog::onAttachClicked() +{ + if (!_baseModel || _boneName.isEmpty()) { + return; + } + + const QString object_name = _ui->objectCombo->currentText(); + if (object_name.isEmpty()) { + return; + } + + QTreeWidgetItem *bone_item = currentBoneItem(); + if (!bone_item) { + return; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + return; + } + + if (_attachMode) { + const QByteArray name_bytes = object_name.toLatin1(); + RenderObjClass *render_obj = asset_manager->Create_Render_Obj(name_bytes.constData()); + if (render_obj) { + _baseModel->Add_Sub_Object_To_Bone(render_obj, _boneName.toLatin1().constData()); + auto *child = new QTreeWidgetItem(bone_item); + child->setText(0, object_name); + render_obj->Release_Ref(); + } + } else { + const int bone_index = _baseModel->Get_Bone_Index(_boneName.toLatin1().constData()); + if (bone_index >= 0) { + const int count = _baseModel->Get_Num_Sub_Objects_On_Bone(bone_index); + for (int index = 0; index < count; ++index) { + RenderObjClass *sub_obj = _baseModel->Get_Sub_Object_On_Bone(index, bone_index); + if (!sub_obj) { + continue; + } + + const QString sub_name = NormalizeName(sub_obj->Get_Name()); + if (sub_name.compare(object_name, Qt::CaseInsensitive) == 0) { + _baseModel->Remove_Sub_Object(sub_obj); + sub_obj->Release_Ref(); + removeObjectFromBone(bone_item, object_name); + break; + } + + sub_obj->Release_Ref(); + } + } + } + + _ui->boneTree->setCurrentItem(bone_item); + updateControls(bone_item); +} + +void BoneManagementDialog::populateBones() +{ + if (!_baseModel) { + return; + } + + const int bone_count = _baseModel->Get_Num_Bones(); + for (int index = 0; index < bone_count; ++index) { + const char *bone_name = _baseModel->Get_Bone_Name(index); + if (!bone_name || !bone_name[0]) { + continue; + } + + auto *bone_item = new QTreeWidgetItem(_ui->boneTree); + bone_item->setText(0, QString::fromLatin1(bone_name)); + fillBoneItem(bone_item, index); + } + + _ui->boneTree->sortItems(0, Qt::AscendingOrder); +} + +void BoneManagementDialog::populateObjectList() +{ + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + return; + } + + RenderObjIterator *iterator = asset_manager->Create_Render_Obj_Iterator(); + if (!iterator) { + return; + } + + QStringList names; + for (iterator->First(); !iterator->Is_Done(); iterator->Next()) { + const char *name = iterator->Current_Item_Name(); + if (!name || !name[0]) { + continue; + } + + if (!asset_manager->Render_Obj_Exists(name)) { + continue; + } + + names.append(QString::fromLatin1(name)); + } + + asset_manager->Release_Render_Obj_Iterator(iterator); + + names.removeDuplicates(); + std::sort(names.begin(), names.end(), [](const QString &a, const QString &b) { + return QString::compare(a, b, Qt::CaseInsensitive) < 0; + }); + + _ui->objectCombo->clear(); + _ui->objectCombo->addItems(names); + if (_ui->objectCombo->count() > 0) { + _ui->objectCombo->setCurrentIndex(0); + } +} + +void BoneManagementDialog::fillBoneItem(QTreeWidgetItem *boneItem, int boneIndex) +{ + if (!boneItem || !_baseModel) { + return; + } + + const char *base_name = _baseModel->Get_Base_Model_Name(); + if (!base_name || !base_name[0]) { + base_name = _baseModel->Get_Name(); + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager || !base_name) { + return; + } + + RenderObjClass *orig_model = asset_manager->Create_Render_Obj(base_name); + if (!orig_model) { + return; + } + + QStringList original_names; + const int orig_count = orig_model->Get_Num_Sub_Objects_On_Bone(boneIndex); + for (int index = 0; index < orig_count; ++index) { + RenderObjClass *sub_obj = orig_model->Get_Sub_Object_On_Bone(index, boneIndex); + if (!sub_obj) { + continue; + } + + original_names.append(NormalizeName(sub_obj->Get_Name())); + sub_obj->Release_Ref(); + } + + const int count = _baseModel->Get_Num_Sub_Objects_On_Bone(boneIndex); + for (int index = 0; index < count; ++index) { + RenderObjClass *sub_obj = _baseModel->Get_Sub_Object_On_Bone(index, boneIndex); + if (!sub_obj) { + continue; + } + + const QString sub_name = NormalizeName(sub_obj->Get_Name()); + const bool exists = original_names.contains(sub_name, Qt::CaseInsensitive); + if (!exists && !sub_name.isEmpty()) { + auto *child = new QTreeWidgetItem(boneItem); + child->setText(0, sub_name); + } + + sub_obj->Release_Ref(); + } + + orig_model->Release_Ref(); +} + +void BoneManagementDialog::updateControls(QTreeWidgetItem *selectedItem) +{ + if (!selectedItem) { + _boneName.clear(); + _ui->boneGroup->setTitle("Bone:"); + _ui->attachButton->setEnabled(false); + return; + } + + QTreeWidgetItem *bone_item = selectedItem->parent() ? selectedItem->parent() : selectedItem; + _boneName = bone_item->text(0); + _ui->boneGroup->setTitle(QString("Bone: %1").arg(_boneName)); + + if (selectedItem->parent()) { + const QString child_name = selectedItem->text(0); + const int index = _ui->objectCombo->findText(child_name, Qt::MatchFixedString); + if (index >= 0) { + _ui->objectCombo->setCurrentIndex(index); + } + } + + updateAttachButton(); +} + +void BoneManagementDialog::updateAttachButton() +{ + const QString current_name = _ui->objectCombo->currentText(); + if (_boneName.isEmpty() || current_name.isEmpty()) { + _ui->attachButton->setEnabled(false); + return; + } + + if (isRenderObjAlreadyAttached(current_name)) { + _ui->attachButton->setText("Remove"); + _attachMode = false; + } else { + _ui->attachButton->setText("Attach"); + _attachMode = true; + } + + _ui->attachButton->setEnabled(true); +} + +bool BoneManagementDialog::isRenderObjAlreadyAttached(const QString &name) const +{ + QTreeWidgetItem *bone_item = currentBoneItem(); + if (!bone_item) { + return false; + } + + const int count = bone_item->childCount(); + for (int index = 0; index < count; ++index) { + QTreeWidgetItem *child = bone_item->child(index); + if (child && child->text(0).compare(name, Qt::CaseInsensitive) == 0) { + return true; + } + } + + return false; +} + +QTreeWidgetItem *BoneManagementDialog::currentBoneItem() const +{ + QTreeWidgetItem *current = _ui->boneTree->currentItem(); + if (!current) { + return nullptr; + } + + return current->parent() ? current->parent() : current; +} + +void BoneManagementDialog::removeObjectFromBone(QTreeWidgetItem *boneItem, const QString &name) +{ + if (!boneItem) { + return; + } + + const int count = boneItem->childCount(); + for (int index = 0; index < count; ++index) { + QTreeWidgetItem *child = boneItem->child(index); + if (child && child->text(0).compare(name, Qt::CaseInsensitive) == 0) { + delete boneItem->takeChild(index); + break; + } + } +} diff --git a/Code/Tools/W3DViewQt/BoneManagementDialog.h b/Code/Tools/W3DViewQt/BoneManagementDialog.h new file mode 100644 index 000000000..a8a5ab62f --- /dev/null +++ b/Code/Tools/W3DViewQt/BoneManagementDialog.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include + +class QTreeWidgetItem; +class RenderObjClass; +class W3DViewport; + +namespace Ui { +class BoneManagementDialog; +} + +class BoneManagementDialog final : public QDialog +{ + Q_OBJECT + +public: + explicit BoneManagementDialog(RenderObjClass *baseModel, + W3DViewport *viewport, + QWidget *parent = nullptr); + ~BoneManagementDialog() override; + +protected: + void accept() override; + void reject() override; + +private slots: + void onBoneSelectionChanged(QTreeWidgetItem *current, QTreeWidgetItem *previous); + void onObjectSelectionChanged(int index); + void onAttachClicked(); + +private: + void populateBones(); + void populateObjectList(); + void fillBoneItem(QTreeWidgetItem *boneItem, int boneIndex); + void updateControls(QTreeWidgetItem *selectedItem); + void updateAttachButton(); + bool isRenderObjAlreadyAttached(const QString &name) const; + QTreeWidgetItem *currentBoneItem() const; + void removeObjectFromBone(QTreeWidgetItem *boneItem, const QString &name); + + RenderObjClass *_baseModel = nullptr; + RenderObjClass *_backupModel = nullptr; + W3DViewport *_viewport = nullptr; + + Ui::BoneManagementDialog *_ui = nullptr; + QString _boneName; + bool _attachMode = true; +}; diff --git a/Code/Tools/W3DViewQt/BoneManagementDialog.ui b/Code/Tools/W3DViewQt/BoneManagementDialog.ui new file mode 100644 index 000000000..81f3d0704 --- /dev/null +++ b/Code/Tools/W3DViewQt/BoneManagementDialog.ui @@ -0,0 +1,68 @@ + + + BoneManagementDialog + + + Bone Management + + + + + + Bone: + + + + + + QAbstractItemView::SingleSelection + + + true + + + + Bone/Attachment + + + + + + + + + + + + + Object: + + + objectCombo + + + + + + + + + + Attach + + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + diff --git a/Code/Tools/W3DViewQt/CMakeLists.txt b/Code/Tools/W3DViewQt/CMakeLists.txt new file mode 100644 index 000000000..ccff9c014 --- /dev/null +++ b/Code/Tools/W3DViewQt/CMakeLists.txt @@ -0,0 +1,797 @@ +set(W3DVIEW_QT_SRC + main.cpp + MainWindow.cpp + MainWindow.h + AggregateNameDialog.cpp + AggregateNameDialog.h + AddToLineupDialog.cpp + AddToLineupDialog.h + AdvancedAnimationDialog.cpp + AdvancedAnimationDialog.h + AnimationPropertiesDialog.cpp + AnimationPropertiesDialog.h + AnimationSettingsDialog.cpp + AnimationSettingsDialog.h + AnimatedSoundOptionsDialog.cpp + AnimatedSoundOptionsDialog.h + BackgroundBitmapDialog.cpp + BackgroundBitmapDialog.h + BackgroundObjectDialog.cpp + BackgroundObjectDialog.h + BoneManagementDialog.cpp + BoneManagementDialog.h + CameraDistanceDialog.cpp + CameraDistanceDialog.h + CameraSettingsDialog.cpp + CameraSettingsDialog.h + ColorLightDialog.cpp + ColorLightDialog.h + EmitterEditDialog.cpp + EmitterEditDialog.h + ExportDirectoryDialog.cpp + ExportDirectoryDialog.h + GammaDialog.cpp + GammaDialog.h + KeyframeTableUtils.cpp + KeyframeTableUtils.h + HierarchyPropertiesDialog.cpp + HierarchyPropertiesDialog.h + RenderObjUtils.cpp + RenderObjUtils.h + MeshPropertiesDialog.cpp + MeshPropertiesDialog.h + OpacityVectorEditDialog.cpp + OpacityVectorEditDialog.h + ResolutionDialog.cpp + ResolutionDialog.h + RingEditDialog.cpp + RingEditDialog.h + PlaySoundDialog.cpp + PlaySoundDialog.h + ScaleDialog.cpp + ScaleDialog.h + SaveSettingsDialog.cpp + SaveSettingsDialog.h + SceneLightDialog.cpp + SceneLightDialog.h + SphereEditDialog.cpp + SphereEditDialog.h + SoundEditDialog.cpp + SoundEditDialog.h + TexturePathDialog.cpp + TexturePathDialog.h + W3DExportUtils.cpp + W3DExportUtils.h + W3DViewport.cpp + W3DViewport.h + W3DViewQt.qrc + W3DViewQt.rc +) + +set(W3DVIEW_QT_UI + MainWindow.ui + AdvancedAnimationDialog.ui + AggregateNameDialog.ui + AddToLineupDialog.ui + AnimatedSoundOptionsDialog.ui + AnimationPropertiesDialog.ui + AnimationSettingsDialog.ui + BackgroundBitmapDialog.ui + BackgroundObjectDialog.ui + BoneManagementDialog.ui + CameraDistanceDialog.ui + CameraSettingsDialog.ui + ColorLightDialog.ui + EmitterEditDialog.ui + ExportDirectoryDialog.ui + GammaDialog.ui + HierarchyPropertiesDialog.ui + MeshPropertiesDialog.ui + OpacityVectorEditDialog.ui + PlaySoundDialog.ui + ResolutionDialog.ui + RingEditDialog.ui + ScaleDialog.ui + SaveSettingsDialog.ui + SceneLightDialog.ui + SoundEditDialog.ui + SphereEditDialog.ui + TexturePathDialog.ui +) + +add_executable(w3dview_qt WIN32) + +target_sources(w3dview_qt PRIVATE ${W3DVIEW_QT_SRC} ${W3DVIEW_QT_UI}) + +target_include_directories(w3dview_qt + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_CURRENT_SOURCE_DIR}/../.. + ../../wwdebug + ../../wwlib + ../../ww3d2 + ../../WWMath + ../../wwphys + ../../wwsaveload + ../../WWAudio +) + +target_link_libraries(w3dview_qt + PRIVATE + qtcommon + w3d_qt_toolkit + d3d9lib + version + winmm + wwcommon + wwdebug + wwlib + wwmath + wwphys + wwsaveload + ww3d2 + wwaudio +) + +set_target_properties(w3dview_qt PROPERTIES + AUTOMOC ON + AUTORCC ON + AUTOUIC ON +) + +if(WIN32) + set(_w3dview_qt_windeployqt_target + "${W3D_QT_PACKAGE}::windeployqt") + set(_w3dview_qt_windeployqt_command) + + if(TARGET "${_w3dview_qt_windeployqt_target}") + set(_w3dview_qt_windeployqt_command + "$") + else() + set(_w3dview_qt_windeployqt_hints) + set(_w3dview_qt_qmake_target "${W3D_QT_PACKAGE}::qmake") + if(TARGET "${_w3dview_qt_qmake_target}") + get_target_property(_w3dview_qt_qmake + "${_w3dview_qt_qmake_target}" IMPORTED_LOCATION) + if(NOT _w3dview_qt_qmake) + get_target_property(_w3dview_qt_qmake + "${_w3dview_qt_qmake_target}" IMPORTED_LOCATION_RELEASE) + endif() + if(_w3dview_qt_qmake) + get_filename_component(_w3dview_qt_qt_bin + "${_w3dview_qt_qmake}" DIRECTORY) + list(APPEND _w3dview_qt_windeployqt_hints + "${_w3dview_qt_qt_bin}") + endif() + endif() + + find_program(W3DVIEW_QT_WINDEPLOYQT_EXECUTABLE + NAMES windeployqt.exe windeployqt + HINTS ${_w3dview_qt_windeployqt_hints}) + if(W3DVIEW_QT_WINDEPLOYQT_EXECUTABLE) + set(_w3dview_qt_windeployqt_command + "${W3DVIEW_QT_WINDEPLOYQT_EXECUTABLE}") + endif() + endif() + + if(_w3dview_qt_windeployqt_command) + add_custom_command(TARGET w3dview_qt POST_BUILD + COMMAND "${_w3dview_qt_windeployqt_command}" + "--$,debug,release>" + --no-translations + --dir "$" + "$" + COMMENT "Deploying the Qt runtime for w3dview_qt" + VERBATIM) + else() + message(WARNING + "windeployqt was not found; w3dview_qt will require manual Qt " + "runtime and platform-plugin deployment.") + endif() +endif() + +if(BUILD_TESTING) + find_package(Qt6 ${W3D_QT_MIN_VERSION} COMPONENTS Test REQUIRED) + set(W3DVIEW_QT_TEST_LIBRARY Qt6::Test) + + set(W3DVIEW_QT_PLUGIN_PATH + "$/../plugins") + if(TARGET Qt6::QOffscreenIntegrationPlugin) + set(W3DVIEW_QT_PLUGIN_PATH + "$/..") + elseif(DEFINED QT6_INSTALL_PREFIX AND DEFINED QT6_INSTALL_PLUGINS) + set(W3DVIEW_QT_PLUGIN_PATH + "${QT6_INSTALL_PREFIX}/${QT6_INSTALL_PLUGINS}") + endif() + + set(W3DVIEW_QT_AUDIO_TEST_ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + if(W3D_BUILD_OPTION_OPENAL) + list(APPEND W3DVIEW_QT_AUDIO_TEST_ENVIRONMENT "ALSOFT_DRIVERS=null") + endif() + + set(W3DVIEW_QT_MAIN_WINDOW_TEST_SRC ${W3DVIEW_QT_SRC}) + list(REMOVE_ITEM W3DVIEW_QT_MAIN_WINDOW_TEST_SRC main.cpp W3DViewQt.rc) + + add_executable(w3dview_qt_main_window_tests + tests/MainWindowCommandTests.cpp + ${W3DVIEW_QT_MAIN_WINDOW_TEST_SRC} + ${W3DVIEW_QT_UI} + ) + + target_include_directories(w3dview_qt_main_window_tests + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_CURRENT_SOURCE_DIR}/../.. + ../../wwdebug + ../../wwlib + ../../ww3d2 + ../../WWMath + ../../wwphys + ../../wwsaveload + ../../WWAudio + ) + + target_link_libraries(w3dview_qt_main_window_tests + PRIVATE + ${W3DVIEW_QT_TEST_LIBRARY} + qtcommon + w3d_qt_toolkit + d3d9lib + version + winmm + wwcommon + wwdebug + wwlib + wwmath + wwphys + wwsaveload + ww3d2 + wwaudio + ) + + set_target_properties(w3dview_qt_main_window_tests PROPERTIES + AUTOMOC ON + AUTORCC ON + AUTOUIC ON + ) + + add_test(NAME w3dview_qt_main_window_tests COMMAND w3dview_qt_main_window_tests) + set_tests_properties(w3dview_qt_main_window_tests PROPERTIES + ENVIRONMENT "${W3DVIEW_QT_AUDIO_TEST_ENVIRONMENT}" + ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:$" + ) + + add_executable(w3dview_qt_settings_save_mask_tests + tests/SettingsSaveMaskTests.cpp + ${W3DVIEW_QT_MAIN_WINDOW_TEST_SRC} + ${W3DVIEW_QT_UI} + ) + + target_include_directories(w3dview_qt_settings_save_mask_tests + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_CURRENT_SOURCE_DIR}/../.. + ../../wwdebug + ../../wwlib + ../../ww3d2 + ../../WWMath + ../../wwphys + ../../wwsaveload + ../../WWAudio + ) + + target_link_libraries(w3dview_qt_settings_save_mask_tests + PRIVATE + ${W3DVIEW_QT_TEST_LIBRARY} + qtcommon + w3d_qt_toolkit + d3d9lib + version + winmm + wwcommon + wwdebug + wwlib + wwmath + wwphys + wwsaveload + ww3d2 + wwaudio + ) + + set_target_properties(w3dview_qt_settings_save_mask_tests PROPERTIES + AUTOMOC ON + AUTORCC ON + AUTOUIC ON + ) + + add_test(NAME w3dview_qt_settings_save_mask_tests + COMMAND w3dview_qt_settings_save_mask_tests) + set_tests_properties(w3dview_qt_settings_save_mask_tests PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen" + ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:$" + ) + + add_executable(w3dview_qt_scene_light_tests + tests/SceneLightTests.cpp + RenderObjUtils.cpp + RenderObjUtils.h + SceneLightDialog.cpp + SceneLightDialog.h + SceneLightDialog.ui + W3DViewport.cpp + W3DViewport.h + ) + + target_include_directories(w3dview_qt_scene_light_tests + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_CURRENT_SOURCE_DIR}/../.. + ../../wwdebug + ../../wwlib + ../../ww3d2 + ../../WWMath + ../../wwphys + ../../wwsaveload + ../../WWAudio + ) + + target_link_libraries(w3dview_qt_scene_light_tests + PRIVATE + ${W3DVIEW_QT_TEST_LIBRARY} + qtcommon + w3d_qt_toolkit + d3d9lib + version + winmm + wwcommon + wwdebug + wwlib + wwmath + wwphys + wwsaveload + ww3d2 + wwaudio + ) + + set_target_properties(w3dview_qt_scene_light_tests PROPERTIES + AUTOMOC ON + AUTOUIC ON + ) + + add_test(NAME w3dview_qt_scene_light_tests COMMAND w3dview_qt_scene_light_tests) + set_tests_properties(w3dview_qt_scene_light_tests PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen" + ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:$" + ) + + option(W3DVIEW_QT_ENABLE_NATIVE_VIEWPORT_TESTS + "Build and register native W3DViewQt viewport tests that require Direct3D" + OFF) + + if(WIN32 AND W3DVIEW_QT_ENABLE_NATIVE_VIEWPORT_TESTS) + add_executable(w3dview_qt_viewport_fog_tests EXCLUDE_FROM_ALL + tests/W3DViewportFogTests.cpp + RenderObjUtils.cpp + RenderObjUtils.h + W3DViewport.cpp + W3DViewport.h + ) + + target_include_directories(w3dview_qt_viewport_fog_tests + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_CURRENT_SOURCE_DIR}/../.. + ../../wwdebug + ../../wwlib + ../../ww3d2 + ../../WWMath + ../../wwphys + ../../wwsaveload + ../../WWAudio + ) + + target_link_libraries(w3dview_qt_viewport_fog_tests + PRIVATE + ${W3DVIEW_QT_TEST_LIBRARY} + qtcommon + w3d_qt_toolkit + d3d9lib + version + winmm + wwcommon + wwdebug + wwlib + wwmath + wwphys + wwsaveload + ww3d2 + wwaudio + ) + + set_target_properties(w3dview_qt_viewport_fog_tests PROPERTIES + AUTOMOC ON + ) + + add_test(NAME w3dview_qt_viewport_fog_tests COMMAND w3dview_qt_viewport_fog_tests) + set_tests_properties(w3dview_qt_viewport_fog_tests PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=windows" + ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:$" + LABELS "native" + ) + endif() + + add_executable(w3dview_qt_emitter_edit_tests + tests/EmitterEditDialogTests.cpp + EmitterEditDialog.cpp + EmitterEditDialog.h + EmitterEditDialog.ui + KeyframeTableUtils.cpp + KeyframeTableUtils.h + ) + + target_include_directories(w3dview_qt_emitter_edit_tests + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_CURRENT_SOURCE_DIR}/../.. + ../../wwdebug + ../../wwlib + ../../ww3d2 + ../../WWMath + ../../wwphys + ../../wwsaveload + ../../WWAudio + ) + + target_link_libraries(w3dview_qt_emitter_edit_tests + PRIVATE + ${W3DVIEW_QT_TEST_LIBRARY} + qtcommon + w3d_qt_toolkit + d3d9lib + version + winmm + wwcommon + wwdebug + wwlib + wwmath + wwphys + wwsaveload + ww3d2 + wwaudio + ) + + set_target_properties(w3dview_qt_emitter_edit_tests PROPERTIES + AUTOMOC ON + AUTOUIC ON + ) + + add_test(NAME w3dview_qt_emitter_edit_tests COMMAND w3dview_qt_emitter_edit_tests) + set_tests_properties(w3dview_qt_emitter_edit_tests PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen" + ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:$" + ) + + add_test( + NAME w3dview_qt_designer_forms + COMMAND ${CMAKE_COMMAND} + "-DW3DVIEW_QT_SOURCE_DIR=${CMAKE_CURRENT_SOURCE_DIR}" + "-DW3DVIEW_QT_BINARY_DIR=${CMAKE_CURRENT_BINARY_DIR}/designer-form-test" + "-DQT_UIC_EXECUTABLE=$" + -P "${CMAKE_CURRENT_SOURCE_DIR}/tests/VerifyDesignerForms.cmake" + ) + + add_executable(w3dview_qt_primitive_shader_tests + tests/PrimitiveShaderDialogTests.cpp + KeyframeTableUtils.cpp + KeyframeTableUtils.h + OpacityVectorEditDialog.cpp + OpacityVectorEditDialog.h + OpacityVectorEditDialog.ui + RingEditDialog.cpp + RingEditDialog.h + RingEditDialog.ui + RenderObjUtils.cpp + RenderObjUtils.h + SphereEditDialog.cpp + SphereEditDialog.h + SphereEditDialog.ui + ) + + target_include_directories(w3dview_qt_primitive_shader_tests + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_CURRENT_SOURCE_DIR}/../.. + ../../wwdebug + ../../wwlib + ../../ww3d2 + ../../WWMath + ../../wwphys + ../../wwsaveload + ../../WWAudio + ) + + target_link_libraries(w3dview_qt_primitive_shader_tests + PRIVATE + ${W3DVIEW_QT_TEST_LIBRARY} + qtcommon + w3d_qt_toolkit + d3d9lib + version + winmm + wwcommon + wwdebug + wwlib + wwmath + wwphys + wwsaveload + ww3d2 + wwaudio + ) + + set_target_properties(w3dview_qt_primitive_shader_tests PROPERTIES + AUTOMOC ON + AUTOUIC ON + ) + + add_test(NAME w3dview_qt_primitive_shader_tests COMMAND w3dview_qt_primitive_shader_tests) + set_tests_properties(w3dview_qt_primitive_shader_tests PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen" + ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:$" + ) + + add_executable(w3dview_qt_background_object_dialog_tests + tests/BackgroundObjectDialogTests.cpp + BackgroundObjectDialog.cpp + BackgroundObjectDialog.h + BackgroundObjectDialog.ui + ) + + target_include_directories(w3dview_qt_background_object_dialog_tests + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_CURRENT_SOURCE_DIR}/../.. + ../../wwdebug + ../../wwlib + ../../ww3d2 + ../../WWMath + ../../wwphys + ../../wwsaveload + ../../WWAudio + ) + + target_link_libraries(w3dview_qt_background_object_dialog_tests + PRIVATE + ${W3DVIEW_QT_TEST_LIBRARY} + qtcommon + w3d_qt_toolkit + d3d9lib + version + winmm + wwcommon + wwdebug + wwlib + wwmath + wwphys + wwsaveload + ww3d2 + wwaudio + ) + + set_target_properties(w3dview_qt_background_object_dialog_tests PROPERTIES + AUTOMOC ON + AUTOUIC ON + ) + + add_test(NAME w3dview_qt_background_object_dialog_tests + COMMAND w3dview_qt_background_object_dialog_tests) + set_tests_properties(w3dview_qt_background_object_dialog_tests PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen" + ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:$" + ) + + add_executable(w3dview_qt_sound_dialog_tests + tests/SoundDialogTests.cpp + PlaySoundDialog.cpp + PlaySoundDialog.h + PlaySoundDialog.ui + SoundEditDialog.cpp + SoundEditDialog.h + SoundEditDialog.ui + ) + + target_include_directories(w3dview_qt_sound_dialog_tests + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_CURRENT_SOURCE_DIR}/../.. + ../../wwdebug + ../../wwlib + ../../ww3d2 + ../../WWMath + ../../wwphys + ../../wwsaveload + ../../WWAudio + ) + + target_link_libraries(w3dview_qt_sound_dialog_tests + PRIVATE + ${W3DVIEW_QT_TEST_LIBRARY} + qtcommon + w3d_qt_toolkit + d3d9lib + version + winmm + wwcommon + wwdebug + wwlib + wwmath + wwphys + wwsaveload + ww3d2 + wwaudio + ) + + set_target_properties(w3dview_qt_sound_dialog_tests PROPERTIES + AUTOMOC ON + AUTOUIC ON + ) + + add_test(NAME w3dview_qt_sound_dialog_tests COMMAND w3dview_qt_sound_dialog_tests) + set_tests_properties(w3dview_qt_sound_dialog_tests PROPERTIES + ENVIRONMENT "${W3DVIEW_QT_AUDIO_TEST_ENVIRONMENT}" + ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:$" + ) + + add_executable(w3dview_qt_resolution_dialog_tests + tests/ResolutionDialogTests.cpp + ResolutionDialog.cpp + ResolutionDialog.h + ResolutionDialog.ui + ) + + target_include_directories(w3dview_qt_resolution_dialog_tests + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_CURRENT_SOURCE_DIR}/../.. + ../../wwdebug + ../../wwlib + ../../ww3d2 + ../../WWMath + ../../wwphys + ../../wwsaveload + ../../WWAudio + ) + + target_link_libraries(w3dview_qt_resolution_dialog_tests + PRIVATE + ${W3DVIEW_QT_TEST_LIBRARY} + qtcommon + w3d_qt_toolkit + d3d9lib + version + winmm + wwcommon + wwdebug + wwlib + wwmath + wwphys + wwsaveload + ww3d2 + wwaudio + ) + + set_target_properties(w3dview_qt_resolution_dialog_tests PROPERTIES + AUTOMOC ON + AUTOUIC ON + ) + + add_test(NAME w3dview_qt_resolution_dialog_tests + COMMAND w3dview_qt_resolution_dialog_tests) + set_tests_properties(w3dview_qt_resolution_dialog_tests PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen" + ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:$" + ) + + add_executable(w3dview_qt_export_directory_dialog_tests + tests/ExportDirectoryDialogTests.cpp + ExportDirectoryDialog.cpp + ExportDirectoryDialog.h + ExportDirectoryDialog.ui + ) + + target_include_directories(w3dview_qt_export_directory_dialog_tests + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ) + + target_link_libraries(w3dview_qt_export_directory_dialog_tests + PRIVATE + ${W3DVIEW_QT_TEST_LIBRARY} + ${W3D_QT_PACKAGE}::Widgets + ) + + set_target_properties(w3dview_qt_export_directory_dialog_tests PROPERTIES + AUTOMOC ON + AUTOUIC ON + ) + + add_test(NAME w3dview_qt_export_directory_dialog_tests + COMMAND w3dview_qt_export_directory_dialog_tests) + set_tests_properties(w3dview_qt_export_directory_dialog_tests PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen" + ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:$" + ) + + add_executable(w3dview_qt_export_utils_tests + tests/W3DExportUtilsTests.cpp + W3DExportUtils.cpp + W3DExportUtils.h + ) + + target_include_directories(w3dview_qt_export_utils_tests + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ../../wwlib + ) + + target_link_libraries(w3dview_qt_export_utils_tests + PRIVATE + ${W3DVIEW_QT_TEST_LIBRARY} + wwcommon + wwlib + version + ) + + set_target_properties(w3dview_qt_export_utils_tests PROPERTIES + AUTOMOC ON + ) + + add_test(NAME w3dview_qt_export_utils_tests COMMAND w3dview_qt_export_utils_tests) + set_tests_properties(w3dview_qt_export_utils_tests PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen" + ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:$" + ) + + set(W3DVIEW_QT_RUNTIME_TESTS + w3dview_qt_main_window_tests + w3dview_qt_settings_save_mask_tests + w3dview_qt_scene_light_tests + w3dview_qt_emitter_edit_tests + w3dview_qt_primitive_shader_tests + w3dview_qt_background_object_dialog_tests + w3dview_qt_sound_dialog_tests + w3dview_qt_resolution_dialog_tests + w3dview_qt_export_directory_dialog_tests + w3dview_qt_export_utils_tests + ) + if(WIN32 AND W3DVIEW_QT_ENABLE_NATIVE_VIEWPORT_TESTS) + list(APPEND W3DVIEW_QT_RUNTIME_TESTS w3dview_qt_viewport_fog_tests) + endif() + + # PATH locates the Qt DLLs. Set the matching plugin root as well so GUI tests + # cannot stall behind an invisible platform-plugin error in headless runs. + set_property(TEST ${W3DVIEW_QT_RUNTIME_TESTS} APPEND PROPERTY + ENVIRONMENT_MODIFICATION + "QT_PLUGIN_PATH=set:${W3DVIEW_QT_PLUGIN_PATH}" + ) +endif() diff --git a/Code/Tools/W3DViewQt/CameraDistanceDialog.cpp b/Code/Tools/W3DViewQt/CameraDistanceDialog.cpp new file mode 100644 index 000000000..9ff9dfdb4 --- /dev/null +++ b/Code/Tools/W3DViewQt/CameraDistanceDialog.cpp @@ -0,0 +1,26 @@ +#include "CameraDistanceDialog.h" + +#include "ui_CameraDistanceDialog.h" + +#include + +CameraDistanceDialog::CameraDistanceDialog(float distance, QWidget *parent) + : QDialog(parent) + , _ui(new Ui::CameraDistanceDialog) +{ + _ui->setupUi(this); + _ui->distanceSpinBox->setValue(distance); + + connect(_ui->buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); +} + +CameraDistanceDialog::~CameraDistanceDialog() +{ + delete _ui; +} + +float CameraDistanceDialog::distance() const +{ + return static_cast(_ui->distanceSpinBox->value()); +} diff --git a/Code/Tools/W3DViewQt/CameraDistanceDialog.h b/Code/Tools/W3DViewQt/CameraDistanceDialog.h new file mode 100644 index 000000000..f51181e45 --- /dev/null +++ b/Code/Tools/W3DViewQt/CameraDistanceDialog.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace Ui { +class CameraDistanceDialog; +} + +class CameraDistanceDialog final : public QDialog +{ + Q_OBJECT + +public: + explicit CameraDistanceDialog(float distance, QWidget *parent = nullptr); + ~CameraDistanceDialog() override; + + float distance() const; + +private: + Ui::CameraDistanceDialog *_ui = nullptr; +}; diff --git a/Code/Tools/W3DViewQt/CameraDistanceDialog.ui b/Code/Tools/W3DViewQt/CameraDistanceDialog.ui new file mode 100644 index 000000000..72ea3aca9 --- /dev/null +++ b/Code/Tools/W3DViewQt/CameraDistanceDialog.ui @@ -0,0 +1,43 @@ + + + CameraDistanceDialog + + + Camera Distance + + + + + + &Camera Distance: + + + distanceSpinBox + + + + + + + 2 + + + 25000.000000000000000 + + + 10.000000000000000 + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + diff --git a/Code/Tools/W3DViewQt/CameraSettingsDialog.cpp b/Code/Tools/W3DViewQt/CameraSettingsDialog.cpp new file mode 100644 index 000000000..13ee7764e --- /dev/null +++ b/Code/Tools/W3DViewQt/CameraSettingsDialog.cpp @@ -0,0 +1,188 @@ +#include "CameraSettingsDialog.h" + +#include "W3DViewport.h" +#include "ui_CameraSettingsDialog.h" + +#include +#include +#include +#include +#include + +namespace { +constexpr double kPi = 3.14159265358979323846; +constexpr double kRadToDeg = 180.0 / kPi; +constexpr double kDegToRad = kPi / 180.0; +constexpr double kLensConstant = 18.0 / 1000.0; +} // namespace + +CameraSettingsDialog::CameraSettingsDialog(W3DViewport *viewport, QWidget *parent) + : QDialog(parent) + , _ui(new Ui::CameraSettingsDialog) + , _viewport(viewport) +{ + _ui->setupUi(this); + + connect(_ui->clipCheckBox, &QCheckBox::toggled, + this, &CameraSettingsDialog::onClipCheckChanged); + connect(_ui->fovCheckBox, &QCheckBox::toggled, + this, &CameraSettingsDialog::onFovCheckChanged); + connect(_ui->lensSpinBox, qOverload(&QDoubleSpinBox::valueChanged), + this, &CameraSettingsDialog::onLensChanged); + connect(_ui->hfovSpinBox, qOverload(&QDoubleSpinBox::valueChanged), + this, &CameraSettingsDialog::onHfovChanged); + auto *reset_button = _ui->buttonBox->button(QDialogButtonBox::Reset); + connect(reset_button, &QPushButton::clicked, this, &CameraSettingsDialog::onReset); + connect(_ui->buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + + refreshFromViewport(); +} + +CameraSettingsDialog::~CameraSettingsDialog() +{ + delete _ui; +} + +bool CameraSettingsDialog::isManualFovEnabled() const +{ + return _ui->fovCheckBox->isChecked(); +} + +bool CameraSettingsDialog::isManualClipPlanesEnabled() const +{ + return _ui->clipCheckBox->isChecked(); +} + +double CameraSettingsDialog::hfovDegrees() const +{ + return _ui->hfovSpinBox->value(); +} + +double CameraSettingsDialog::vfovDegrees() const +{ + return _ui->vfovSpinBox->value(); +} + +double CameraSettingsDialog::lensMm() const +{ + return _ui->lensSpinBox->value(); +} + +float CameraSettingsDialog::nearClip() const +{ + return static_cast(_ui->nearClipSpinBox->value()); +} + +float CameraSettingsDialog::farClip() const +{ + return static_cast(_ui->farClipSpinBox->value()); +} + +void CameraSettingsDialog::onFovCheckChanged(bool checked) +{ + setFovControlsEnabled(checked); +} + +void CameraSettingsDialog::onClipCheckChanged(bool checked) +{ + setClipControlsEnabled(checked); +} + +void CameraSettingsDialog::onReset() +{ + if (_viewport) { + _viewport->setManualFovEnabled(false); + _viewport->setManualClipPlanesEnabled(false); + _viewport->resetFov(); + _viewport->resetCamera(); + } + + refreshFromViewport(); +} + +void CameraSettingsDialog::onHfovChanged(double value) +{ + Q_UNUSED(value); + updateLensFromHfov(); +} + +void CameraSettingsDialog::onLensChanged(double value) +{ + Q_UNUSED(value); + updateFovFromLens(); +} + +void CameraSettingsDialog::refreshFromViewport() +{ + if (!_viewport) { + return; + } + + const bool manual_fov = _viewport->isManualFovEnabled(); + const bool manual_clip = _viewport->isManualClipPlanesEnabled(); + _ui->fovCheckBox->setChecked(manual_fov); + _ui->clipCheckBox->setChecked(manual_clip); + + double hfov_deg = 0.0; + double vfov_deg = 0.0; + _viewport->cameraFovDegrees(hfov_deg, vfov_deg); + _ui->hfovSpinBox->setValue(hfov_deg); + _ui->vfovSpinBox->setValue(vfov_deg); + + updateLensFromHfov(); + + float znear = 0.0f; + float zfar = 0.0f; + _viewport->cameraClipPlanes(znear, zfar); + _ui->nearClipSpinBox->setValue(znear); + _ui->farClipSpinBox->setValue(zfar); + + setFovControlsEnabled(manual_fov); + setClipControlsEnabled(manual_clip); +} + +void CameraSettingsDialog::updateLensFromHfov() +{ + if (_updating) { + return; + } + + _updating = true; + const double hfov_rad = _ui->hfovSpinBox->value() * kDegToRad; + if (hfov_rad > 0.0) { + const double lens = (kLensConstant / std::tan(hfov_rad / 2.0)) * 1000.0; + _ui->lensSpinBox->setValue(lens); + } + _updating = false; +} + +void CameraSettingsDialog::updateFovFromLens() +{ + if (_updating) { + return; + } + + _updating = true; + const double lens = _ui->lensSpinBox->value() / 1000.0; + if (lens > 0.0) { + const double hfov = std::atan(kLensConstant / lens) * 2.0; + const double vfov = (3.0 * hfov) / 4.0; + _ui->hfovSpinBox->setValue(hfov * kRadToDeg); + _ui->vfovSpinBox->setValue(vfov * kRadToDeg); + } + _updating = false; +} + +void CameraSettingsDialog::setFovControlsEnabled(bool enabled) +{ + _ui->hfovSpinBox->setEnabled(enabled); + _ui->vfovSpinBox->setEnabled(enabled); + _ui->lensSpinBox->setEnabled(enabled); +} + +void CameraSettingsDialog::setClipControlsEnabled(bool enabled) +{ + _ui->nearClipSpinBox->setEnabled(enabled); + _ui->farClipSpinBox->setEnabled(enabled); +} diff --git a/Code/Tools/W3DViewQt/CameraSettingsDialog.h b/Code/Tools/W3DViewQt/CameraSettingsDialog.h new file mode 100644 index 000000000..70892c89a --- /dev/null +++ b/Code/Tools/W3DViewQt/CameraSettingsDialog.h @@ -0,0 +1,44 @@ +#pragma once + +#include + +class W3DViewport; + +namespace Ui { +class CameraSettingsDialog; +} + +class CameraSettingsDialog final : public QDialog +{ + Q_OBJECT + +public: + explicit CameraSettingsDialog(W3DViewport *viewport, QWidget *parent = nullptr); + ~CameraSettingsDialog() override; + + bool isManualFovEnabled() const; + bool isManualClipPlanesEnabled() const; + double hfovDegrees() const; + double vfovDegrees() const; + double lensMm() const; + float nearClip() const; + float farClip() const; + +private slots: + void onFovCheckChanged(bool checked); + void onClipCheckChanged(bool checked); + void onReset(); + void onHfovChanged(double value); + void onLensChanged(double value); + +private: + void refreshFromViewport(); + void updateLensFromHfov(); + void updateFovFromLens(); + void setFovControlsEnabled(bool enabled); + void setClipControlsEnabled(bool enabled); + + Ui::CameraSettingsDialog *_ui = nullptr; + W3DViewport *_viewport = nullptr; + bool _updating = false; +}; diff --git a/Code/Tools/W3DViewQt/CameraSettingsDialog.ui b/Code/Tools/W3DViewQt/CameraSettingsDialog.ui new file mode 100644 index 000000000..cf091e0a2 --- /dev/null +++ b/Code/Tools/W3DViewQt/CameraSettingsDialog.ui @@ -0,0 +1,182 @@ + + + CameraSettingsDialog + + + Camera Settings + + + + + + Use the controls below to specify the camera's clip planes and aspect ratio. + + + true + + + + + + + &Clip Planes + + + + + + + + + &Near: + + + nearClipSpinBox + + + + + + + 2 + + + 999999.000000000000000 + + + 0.100000000000000 + + + + + + + &Far: + + + farClipSpinBox + + + + + + + 2 + + + 1.000000000000000 + + + 999999.000000000000000 + + + 1.000000000000000 + + + + + + + + + Field of &View + + + + + + + + + &Camera Lens: + + + lensSpinBox + + + + + + + 2 + + + mm + + + 1.000000000000000 + + + 200.000000000000000 + + + 1.000000000000000 + + + + + + + &Horizontal: + + + hfovSpinBox + + + + + + + 2 + + + deg + + + 180.000000000000000 + + + 1.000000000000000 + + + + + + + V&ertical: + + + vfovSpinBox + + + + + + + 2 + + + deg + + + 180.000000000000000 + + + 1.000000000000000 + + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok|QDialogButtonBox::Reset + + + + + + + + diff --git a/Code/Tools/W3DViewQt/ColorLightDialog.cpp b/Code/Tools/W3DViewQt/ColorLightDialog.cpp new file mode 100644 index 000000000..c043292fd --- /dev/null +++ b/Code/Tools/W3DViewQt/ColorLightDialog.cpp @@ -0,0 +1,110 @@ +#include "ColorLightDialog.h" + +#include "ui_ColorLightDialog.h" + +#include +#include +#include +#include +#include + +#include + +ColorLightDialog::ColorLightDialog(const QString &title, + const Vector3 &initialColor, + ApplyCallback applyCallback, + QWidget *parent) + : QDialog(parent) + , _ui(new Ui::ColorLightDialog) + , _initialColor(initialColor) + , _applyCallback(std::move(applyCallback)) +{ + _ui->setupUi(this); + setWindowTitle(title); + + _ui->redSlider->setValue(static_cast(_initialColor.X * 100.0f)); + _ui->greenSlider->setValue(static_cast(_initialColor.Y * 100.0f)); + _ui->blueSlider->setValue(static_cast(_initialColor.Z * 100.0f)); + _ui->grayscaleCheckBox->setChecked( + _initialColor.X == _initialColor.Y && _initialColor.X == _initialColor.Z); + updateValueLabels(); + + connect(_ui->redSlider, &QSlider::valueChanged, this, + [this](int value) { colorSliderChanged(_ui->redSlider, value); }); + connect(_ui->greenSlider, &QSlider::valueChanged, this, + [this](int value) { colorSliderChanged(_ui->greenSlider, value); }); + connect(_ui->blueSlider, &QSlider::valueChanged, this, + [this](int value) { colorSliderChanged(_ui->blueSlider, value); }); + connect(_ui->grayscaleCheckBox, &QCheckBox::toggled, this, [this](bool enabled) { + if (!enabled) { + return; + } + + const int value = _ui->redSlider->value(); + const QSignalBlocker greenBlocker(_ui->greenSlider); + const QSignalBlocker blueBlocker(_ui->blueSlider); + _ui->greenSlider->setValue(value); + _ui->blueSlider->setValue(value); + updateValueLabels(); + applySelectedColor(); + }); + + connect(_ui->buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &ColorLightDialog::reject); +} + +ColorLightDialog::~ColorLightDialog() +{ + delete _ui; +} + +Vector3 ColorLightDialog::selectedColor() const +{ + return Vector3(static_cast(_ui->redSlider->value()) / 100.0f, + static_cast(_ui->greenSlider->value()) / 100.0f, + static_cast(_ui->blueSlider->value()) / 100.0f); +} + +void ColorLightDialog::reject() +{ + if (_applyCallback) { + _applyCallback(_initialColor); + } + + QDialog::reject(); +} + +void ColorLightDialog::colorSliderChanged(QSlider *source, int value) +{ + if (_ui->grayscaleCheckBox->isChecked()) { + const QSignalBlocker redBlocker(_ui->redSlider); + const QSignalBlocker greenBlocker(_ui->greenSlider); + const QSignalBlocker blueBlocker(_ui->blueSlider); + if (source != _ui->redSlider) { + _ui->redSlider->setValue(value); + } + if (source != _ui->greenSlider) { + _ui->greenSlider->setValue(value); + } + if (source != _ui->blueSlider) { + _ui->blueSlider->setValue(value); + } + } + + updateValueLabels(); + applySelectedColor(); +} + +void ColorLightDialog::applySelectedColor() +{ + if (_applyCallback) { + _applyCallback(selectedColor()); + } +} + +void ColorLightDialog::updateValueLabels() +{ + _ui->redValueLabel->setText(QString::number(_ui->redSlider->value())); + _ui->greenValueLabel->setText(QString::number(_ui->greenSlider->value())); + _ui->blueValueLabel->setText(QString::number(_ui->blueSlider->value())); +} diff --git a/Code/Tools/W3DViewQt/ColorLightDialog.h b/Code/Tools/W3DViewQt/ColorLightDialog.h new file mode 100644 index 000000000..68897ee69 --- /dev/null +++ b/Code/Tools/W3DViewQt/ColorLightDialog.h @@ -0,0 +1,41 @@ +#pragma once + +#include "vector3.h" + +#include +#include + +#include + +class QSlider; +namespace Ui { +class ColorLightDialog; +} + +class ColorLightDialog final : public QDialog +{ + Q_OBJECT + +public: + using ApplyCallback = std::function; + + explicit ColorLightDialog(const QString &title, + const Vector3 &initialColor, + ApplyCallback applyCallback, + QWidget *parent = nullptr); + ~ColorLightDialog() override; + + Vector3 selectedColor() const; + +public slots: + void reject() override; + +private: + void colorSliderChanged(QSlider *source, int value); + void applySelectedColor(); + void updateValueLabels(); + + Ui::ColorLightDialog *_ui = nullptr; + Vector3 _initialColor; + ApplyCallback _applyCallback; +}; diff --git a/Code/Tools/W3DViewQt/ColorLightDialog.ui b/Code/Tools/W3DViewQt/ColorLightDialog.ui new file mode 100644 index 000000000..57595ce3c --- /dev/null +++ b/Code/Tools/W3DViewQt/ColorLightDialog.ui @@ -0,0 +1,145 @@ + + + ColorLightDialog + + + Color + + + + + + + + &Red + + + redSlider + + + + + + + + 220 + 0 + + + + 100 + + + 10 + + + Qt::Horizontal + + + + + + + + 28 + 0 + + + + 0 + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + &Green + + + greenSlider + + + + + + + 100 + + + 10 + + + Qt::Horizontal + + + + + + + 0 + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + &Blue + + + blueSlider + + + + + + + 100 + + + 10 + + + Qt::Horizontal + + + + + + + 0 + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + &Grayscale + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + diff --git a/Code/Tools/W3DViewQt/EmitterEditDialog.cpp b/Code/Tools/W3DViewQt/EmitterEditDialog.cpp new file mode 100644 index 000000000..2f08c0650 --- /dev/null +++ b/Code/Tools/W3DViewQt/EmitterEditDialog.cpp @@ -0,0 +1,1304 @@ +#include "EmitterEditDialog.h" + +#include "ui_EmitterEditDialog.h" + +#include "KeyframeTableUtils.h" + +#include "part_ldr.h" +#include "shader.h" +#include "v3_rnd.h" +#include "vector2.h" +#include "vector3.h" +#include "w3d_file.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr double kWideMinimum = -1000000000.0; +constexpr double kWideMaximum = 1000000000.0; +constexpr double kMaximumKeyTime = 5000000.0; + +struct ShaderPreset { + const char *label; + ShaderClass shader; +}; + +ShaderPreset BuildPreset(const char *label, const ShaderClass &shader) +{ + return ShaderPreset{label, shader}; +} + +const ShaderPreset *ShaderPresets(int &count) +{ + static ShaderPreset presets[] = { + BuildPreset("Additive", ShaderClass::_PresetAdditiveSpriteShader), + BuildPreset("Alpha", ShaderClass::_PresetAlphaSpriteShader), + BuildPreset("Alpha-Test", ShaderClass::_PresetATestSpriteShader), + BuildPreset("Alpha-Test-Blend", ShaderClass::_PresetATestBlendSpriteShader), + BuildPreset("Screen", ShaderClass::_PresetScreenSpriteShader), + BuildPreset("Multiplicative", ShaderClass::_PresetMultiplicativeSpriteShader), + BuildPreset("Opaque", ShaderClass::_PresetOpaqueSpriteShader), + }; + + count = static_cast(sizeof(presets) / sizeof(presets[0])); + return presets; +} + +bool ShaderMatches(const ShaderClass &a, const ShaderClass &b) +{ + return a.Get_Bits() == b.Get_Bits(); +} + +void ConfigureSpin(QDoubleSpinBox *spin, + double minimum = kWideMinimum, + double maximum = kWideMaximum, + int decimals = 6) +{ + spin->setRange(minimum, maximum); + spin->setDecimals(decimals); + spin->setKeyboardTracking(false); +} + +void ConfigureTable(QTableWidget *table, const QStringList &headers) +{ + table->setColumnCount(headers.size()); + table->setHorizontalHeaderLabels(headers); + table->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); + table->setSelectionBehavior(QAbstractItemView::SelectRows); + table->setSelectionMode(QAbstractItemView::ExtendedSelection); + table->setEditTriggers(QAbstractItemView::NoEditTriggers); + table->setSortingEnabled(false); + table->setShowGrid(true); +} + +const QVector &ColorKeySpecs() +{ + static const QVector specs = { + {0.0, kMaximumKeyTime, 6}, + {0.0, 1.0, 6}, + {0.0, 1.0, 6}, + {0.0, 1.0, 6}, + }; + return specs; +} + +const QVector &OpacityKeySpecs() +{ + static const QVector specs = { + {0.0, kMaximumKeyTime, 6}, + {0.0, 1.0, 6}, + }; + return specs; +} + +const QVector &ScalarKeySpecs() +{ + static const QVector specs = { + {0.0, kMaximumKeyTime, 6}, + {kWideMinimum, kWideMaximum, 6}, + }; + return specs; +} + +template +void FreeProperty(ParticlePropertyStruct &property) +{ + delete[] property.KeyTimes; + delete[] property.Values; + property.KeyTimes = nullptr; + property.Values = nullptr; + property.NumKeyFrames = 0; +} + +QVector> SortedRows(const QTableWidget *table) +{ + QVector> rows = GetKeyframeRows(table); + std::sort(rows.begin(), rows.end(), [](const QVector &a, const QVector &b) { + const double timeA = a.isEmpty() ? 0.0 : a[0]; + const double timeB = b.isEmpty() ? 0.0 : b[0]; + return timeA < timeB; + }); + return rows; +} + +void ReplaceScalarKeys(ParticlePropertyStruct &property, const QTableWidget *table) +{ + const QVector> rows = SortedRows(table); + delete[] property.KeyTimes; + delete[] property.Values; + property.NumKeyFrames = static_cast(rows.size()); + property.KeyTimes = property.NumKeyFrames ? new float[property.NumKeyFrames] : nullptr; + property.Values = property.NumKeyFrames ? new float[property.NumKeyFrames] : nullptr; + for (unsigned int index = 0; index < property.NumKeyFrames; ++index) { + const QVector &row = rows[static_cast(index)]; + property.KeyTimes[index] = row.isEmpty() ? 0.0f : static_cast(row[0]); + property.Values[index] = row.size() < 2 ? 0.0f : static_cast(row[1]); + } +} + +void ReplaceVectorKeys(ParticlePropertyStruct &property, const QTableWidget *table) +{ + const QVector> rows = SortedRows(table); + delete[] property.KeyTimes; + delete[] property.Values; + property.NumKeyFrames = static_cast(rows.size()); + property.KeyTimes = property.NumKeyFrames ? new float[property.NumKeyFrames] : nullptr; + property.Values = property.NumKeyFrames ? new Vector3[property.NumKeyFrames] : nullptr; + for (unsigned int index = 0; index < property.NumKeyFrames; ++index) { + const QVector &row = rows[static_cast(index)]; + property.KeyTimes[index] = row.isEmpty() ? 0.0f : static_cast(row[0]); + property.Values[index] = Vector3(row.size() > 1 ? static_cast(row[1]) : 0.0f, + row.size() > 2 ? static_cast(row[2]) : 0.0f, + row.size() > 3 ? static_cast(row[3]) : 0.0f); + } +} + +void ScaleTableKeyTimes(QTableWidget *table, float conversion) +{ + if (!table) { + return; + } + + for (int row = 0; row < table->rowCount(); ++row) { + if (auto *timeSpin = qobject_cast(table->cellWidget(row, 0))) { + timeSpin->setValue(timeSpin->value() * conversion); + } + } +} + +double PromptKeyTime(QWidget *parent, const QString &title, bool &ok) +{ + return QInputDialog::getDouble(parent, + title, + "Time (seconds):", + 0.0, + 0.0, + kMaximumKeyTime, + 6, + &ok); +} +} + +EmitterEditDialog::EmitterEditDialog(const ParticleEmitterDefClass &definition, QWidget *parent) + : QDialog(parent) + , _definition(definition) + , _ui(new Ui::EmitterEditDialog) +{ + _ui->setupUi(this); + configureControls(); + loadFromDefinition(); + _registeredName = _originalName; + connectDirtyTracking(); + + connect(_ui->browseButton, &QPushButton::clicked, this, &EmitterEditDialog::browseTexture); + connect(_ui->useLifetimeCheck, &QCheckBox::toggled, this, &EmitterEditDialog::toggleLifetime); + connect(_ui->limitParticlesCheck, &QCheckBox::toggled, this, &EmitterEditDialog::toggleMaxParticles); + connect(_ui->buttonBox, &QDialogButtonBox::accepted, this, &EmitterEditDialog::accept); + connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + if (QPushButton *applyButton = _ui->buttonBox->button(QDialogButtonBox::Apply)) { + connect(applyButton, &QPushButton::clicked, this, &EmitterEditDialog::apply); + } + + updateRenderModeTabs(); + updateApplyButton(); +} + +EmitterEditDialog::~EmitterEditDialog() +{ + delete _ui; +} + +ParticleEmitterDefClass *EmitterEditDialog::definition() const +{ + return new ParticleEmitterDefClass(_definition); +} + +QString EmitterEditDialog::originalName() const +{ + return _originalName; +} + +void EmitterEditDialog::setApplyHandler(ApplyHandler handler, + const QString ®isteredName, + bool initialApplyRequired) +{ + _applyHandler = std::move(handler); + _registeredName = registeredName; + _initialApplyRequired = initialApplyRequired; + updateApplyButton(); +} + +void EmitterEditDialog::configureControls() +{ + int presetCount = 0; + const ShaderPreset *presets = ShaderPresets(presetCount); + for (int index = 0; index < presetCount; ++index) { + _ui->shaderCombo->addItem(presets[index].label, index); + } + + _ui->renderModeCombo->addItem("Triangles", W3D_EMITTER_RENDER_MODE_TRI_PARTICLES); + _ui->renderModeCombo->addItem("Quads", W3D_EMITTER_RENDER_MODE_QUAD_PARTICLES); + _ui->renderModeCombo->addItem("Line", W3D_EMITTER_RENDER_MODE_LINE); + _ui->renderModeCombo->addItem("Line Group (Tetra)", W3D_EMITTER_RENDER_MODE_LINEGRP_TETRA); + _ui->renderModeCombo->addItem("Line Group (Prism)", W3D_EMITTER_RENDER_MODE_LINEGRP_PRISM); + + _ui->frameModeCombo->addItem("1x1", W3D_EMITTER_FRAME_MODE_1x1); + _ui->frameModeCombo->addItem("2x2", W3D_EMITTER_FRAME_MODE_2x2); + _ui->frameModeCombo->addItem("4x4", W3D_EMITTER_FRAME_MODE_4x4); + _ui->frameModeCombo->addItem("8x8", W3D_EMITTER_FRAME_MODE_8x8); + _ui->frameModeCombo->addItem("16x16", W3D_EMITTER_FRAME_MODE_16x16); + + _ui->lineMappingCombo->addItem("Uniform Width", W3D_ELINE_UNIFORM_WIDTH_TEXTURE_MAP); + _ui->lineMappingCombo->addItem("Uniform Length", W3D_ELINE_UNIFORM_LENGTH_TEXTURE_MAP); + _ui->lineMappingCombo->addItem("Tiled", W3D_ELINE_TILED_TEXTURE_MAP); + + for (int index = 0; index < EMITTER_TYPEID_COUNT; ++index) { + _ui->userTypeCombo->addItem(QString::fromLatin1(EMITTER_TYPE_NAMES[index]), index); + } + + const auto populateRandomizers = [](QComboBox *combo) { + combo->addItem("Solid Box", Vector3Randomizer::CLASSID_SOLIDBOX); + combo->addItem("Solid Sphere", Vector3Randomizer::CLASSID_SOLIDSPHERE); + combo->addItem("Hollow Sphere", Vector3Randomizer::CLASSID_HOLLOWSPHERE); + combo->addItem("Solid Cylinder", Vector3Randomizer::CLASSID_SOLIDCYLINDER); + }; + populateRandomizers(_ui->creationTypeCombo); + populateRandomizers(_ui->velocityRandomTypeCombo); + + const QList wideSpins = { + _ui->lifetimeSpin, + _ui->emissionRateSpin, + _ui->burstSizeSpin, + _ui->maxParticlesSpin, + _ui->fadeTimeSpin, + _ui->creationValue1Spin, + _ui->creationValue2Spin, + _ui->creationValue3Spin, + _ui->velocityXSpin, + _ui->velocityYSpin, + _ui->velocityZSpin, + _ui->velocityRandomValue1Spin, + _ui->velocityRandomValue2Spin, + _ui->velocityRandomValue3Spin, + _ui->accelXSpin, + _ui->accelYSpin, + _ui->accelZSpin, + _ui->outwardVelSpin, + _ui->inheritVelSpin, + _ui->gravitySpin, + _ui->elasticitySpin, + _ui->sizeStartSpin, + _ui->sizeRandomSpin, + _ui->lineNoiseSpin, + _ui->lineMergeAbortSpin, + _ui->lineTileSpin, + _ui->lineUSpin, + _ui->lineVSpin, + _ui->rotationStartSpin, + _ui->rotationRandomSpin, + _ui->orientationRandomSpin, + _ui->frameStartSpin, + _ui->frameRandomSpin, + _ui->blurStartSpin, + _ui->blurRandomSpin, + }; + for (QDoubleSpinBox *spin : wideSpins) { + ConfigureSpin(spin); + } + + for (QDoubleSpinBox *spin : {_ui->colorStartRSpin, + _ui->colorStartGSpin, + _ui->colorStartBSpin, + _ui->colorRandomRSpin, + _ui->colorRandomGSpin, + _ui->colorRandomBSpin, + _ui->opacityStartSpin, + _ui->opacityRandomSpin}) { + ConfigureSpin(spin, 0.0, 1.0, 6); + } + ConfigureSpin(_ui->burstSizeSpin, 0.0, 4294967295.0, 0); + ConfigureSpin(_ui->maxParticlesSpin, 0.0, kWideMaximum, 0); + _ui->lineSubdivisionSpin->setRange(0, 8); + + ConfigureTable(_ui->colorKeysTable, {"Time (s)", "Red", "Green", "Blue"}); + ConfigureTable(_ui->opacityKeysTable, {"Time (s)", "Opacity"}); + ConfigureTable(_ui->sizeKeysTable, {"Time (s)", "Size"}); + ConfigureTable(_ui->rotationKeysTable, {"Time (s)", "Rotations / sec"}); + ConfigureTable(_ui->frameKeysTable, {"Time (s)", "Frame / U"}); + ConfigureTable(_ui->blurKeysTable, {"Time (s)", "Blur time"}); +} + +void EmitterEditDialog::connectDirtyTracking() +{ + const auto dirtyLineEdit = [this](QLineEdit *edit, const char *key) { + connect(edit, &QLineEdit::textEdited, this, [this, key]() { markDirty(QString::fromLatin1(key)); }); + }; + const auto dirtyDouble = [this](QDoubleSpinBox *spin, const char *key) { + connect(spin, + qOverload(&QDoubleSpinBox::valueChanged), + this, + [this, key]() { markDirty(QString::fromLatin1(key)); }); + }; + const auto dirtySpin = [this](QSpinBox *spin, const char *key) { + connect(spin, + qOverload(&QSpinBox::valueChanged), + this, + [this, key]() { markDirty(QString::fromLatin1(key)); }); + }; + const auto dirtyCheck = [this](QCheckBox *check, const char *key) { + connect(check, &QCheckBox::toggled, this, [this, key]() { markDirty(QString::fromLatin1(key)); }); + }; + const auto dirtyCombo = [this](QComboBox *combo, const char *key) { + connect(combo, + qOverload(&QComboBox::currentIndexChanged), + this, + [this, key]() { markDirty(QString::fromLatin1(key)); }); + }; + + dirtyLineEdit(_ui->nameEdit, "general.name"); + dirtyLineEdit(_ui->textureEdit, "general.texture"); + dirtyCheck(_ui->useLifetimeCheck, "general.lifetime"); + dirtyDouble(_ui->lifetimeSpin, "general.lifetime"); + dirtyCombo(_ui->shaderCombo, "general.shader"); + dirtyCombo(_ui->renderModeCombo, "general.renderMode"); + connect(_ui->renderModeCombo, + qOverload(&QComboBox::currentIndexChanged), + this, + &EmitterEditDialog::updateRenderModeTabs); + + dirtyDouble(_ui->emissionRateSpin, "particle.rate"); + dirtyDouble(_ui->burstSizeSpin, "particle.burst"); + dirtyCheck(_ui->limitParticlesCheck, "particle.max"); + dirtyDouble(_ui->maxParticlesSpin, "particle.max"); + dirtyDouble(_ui->fadeTimeSpin, "particle.fade"); + dirtyCombo(_ui->creationTypeCombo, "particle.creation"); + dirtyDouble(_ui->creationValue1Spin, "particle.creation"); + dirtyDouble(_ui->creationValue2Spin, "particle.creation"); + dirtyDouble(_ui->creationValue3Spin, "particle.creation"); + connect(_ui->creationTypeCombo, qOverload(&QComboBox::currentIndexChanged), this, [this]() { + updateRandomizerControls(_ui->creationTypeCombo, + _ui->creationValue1Label, + _ui->creationValue2Label, + _ui->creationValue3Label, + _ui->creationValue1Spin, + _ui->creationValue2Spin, + _ui->creationValue3Spin); + }); + + dirtyDouble(_ui->velocityXSpin, "physics.velocity.x"); + dirtyDouble(_ui->velocityYSpin, "physics.velocity.y"); + dirtyDouble(_ui->velocityZSpin, "physics.velocity.z"); + dirtyCombo(_ui->velocityRandomTypeCombo, "physics.randomizer"); + dirtyDouble(_ui->velocityRandomValue1Spin, "physics.randomizer"); + dirtyDouble(_ui->velocityRandomValue2Spin, "physics.randomizer"); + dirtyDouble(_ui->velocityRandomValue3Spin, "physics.randomizer"); + connect(_ui->velocityRandomTypeCombo, + qOverload(&QComboBox::currentIndexChanged), + this, + [this]() { + updateRandomizerControls(_ui->velocityRandomTypeCombo, + _ui->velocityRandomValue1Label, + _ui->velocityRandomValue2Label, + _ui->velocityRandomValue3Label, + _ui->velocityRandomValue1Spin, + _ui->velocityRandomValue2Spin, + _ui->velocityRandomValue3Spin); + }); + dirtyDouble(_ui->accelXSpin, "physics.acceleration.x"); + dirtyDouble(_ui->accelYSpin, "physics.acceleration.y"); + dirtyDouble(_ui->accelZSpin, "physics.acceleration.z"); + dirtyDouble(_ui->outwardVelSpin, "physics.outward"); + dirtyDouble(_ui->inheritVelSpin, "physics.inherit"); + dirtyDouble(_ui->gravitySpin, "physics.gravity"); + dirtyDouble(_ui->elasticitySpin, "physics.elasticity"); + + dirtyDouble(_ui->colorStartRSpin, "color.start.r"); + dirtyDouble(_ui->colorStartGSpin, "color.start.g"); + dirtyDouble(_ui->colorStartBSpin, "color.start.b"); + dirtyDouble(_ui->colorRandomRSpin, "color.random.r"); + dirtyDouble(_ui->colorRandomGSpin, "color.random.g"); + dirtyDouble(_ui->colorRandomBSpin, "color.random.b"); + dirtyDouble(_ui->opacityStartSpin, "opacity.start"); + dirtyDouble(_ui->opacityRandomSpin, "opacity.random"); + + dirtyDouble(_ui->sizeStartSpin, "size.start"); + dirtyDouble(_ui->sizeRandomSpin, "size.random"); + + dirtyCombo(_ui->userTypeCombo, "user.type"); + connect(_ui->userStringEdit, &QPlainTextEdit::textChanged, this, [this]() { markDirty("user.string"); }); + + dirtyCombo(_ui->lineMappingCombo, "line.mapping"); + dirtyCheck(_ui->lineMergeCheck, "line.merge"); + dirtyCheck(_ui->lineFreezeCheck, "line.freeze"); + dirtyCheck(_ui->lineDisableSortingCheck, "line.sorting"); + dirtyCheck(_ui->lineEndCapsCheck, "line.endCaps"); + dirtySpin(_ui->lineSubdivisionSpin, "line.subdivision"); + dirtyDouble(_ui->lineNoiseSpin, "line.noise"); + dirtyDouble(_ui->lineMergeAbortSpin, "line.mergeAbort"); + dirtyDouble(_ui->lineTileSpin, "line.tile"); + dirtyDouble(_ui->lineUSpin, "line.u"); + dirtyDouble(_ui->lineVSpin, "line.v"); + + dirtyDouble(_ui->rotationStartSpin, "rotation.start"); + dirtyDouble(_ui->rotationRandomSpin, "rotation.random"); + dirtyDouble(_ui->orientationRandomSpin, "rotation.orientationRandom"); + + dirtyDouble(_ui->frameStartSpin, "frame.start"); + dirtyDouble(_ui->frameRandomSpin, "frame.random"); + dirtyCombo(_ui->frameModeCombo, "frame.mode"); + + dirtyDouble(_ui->blurStartSpin, "blur.start"); + dirtyDouble(_ui->blurRandomSpin, "blur.random"); + + connectTableEditors(_ui->colorKeysTable, "color.keys"); + connectTableEditors(_ui->opacityKeysTable, "opacity.keys"); + connectTableEditors(_ui->sizeKeysTable, "size.keys"); + connectTableEditors(_ui->rotationKeysTable, "rotation.keys"); + connectTableEditors(_ui->frameKeysTable, "frame.keys"); + connectTableEditors(_ui->blurKeysTable, "blur.keys"); + + const auto connectTableButtons = [this](QPushButton *addButton, + QPushButton *removeButton, + QPushButton *sortButton, + QTableWidget *table, + const QVector &specs, + const QString &dirtyKey, + const QString &title, + const QVector &defaultValues) { + connect(addButton, &QPushButton::clicked, this, [this, table, specs, dirtyKey, title, defaultValues]() { + bool ok = false; + const double time = PromptKeyTime(this, title, ok); + if (!ok) { + return; + } + QVector values{time}; + values += defaultValues; + AddKeyframeRow(table, values, specs); + SortKeyframeRows(table, specs); + connectTableEditors(table, dirtyKey); + markDirty(dirtyKey); + }); + connect(removeButton, &QPushButton::clicked, this, [this, table, dirtyKey]() { + RemoveSelectedKeyframeRows(table); + markDirty(dirtyKey); + }); + connect(sortButton, &QPushButton::clicked, this, [this, table, specs, dirtyKey]() { + SortKeyframeRows(table, specs); + connectTableEditors(table, dirtyKey); + markDirty(dirtyKey); + }); + }; + + connectTableButtons(_ui->colorAddButton, + _ui->colorRemoveButton, + _ui->colorSortButton, + _ui->colorKeysTable, + ColorKeySpecs(), + "color.keys", + "Add Color Key", + {_ui->colorStartRSpin->value(), _ui->colorStartGSpin->value(), _ui->colorStartBSpin->value()}); + connectTableButtons(_ui->opacityAddButton, + _ui->opacityRemoveButton, + _ui->opacitySortButton, + _ui->opacityKeysTable, + OpacityKeySpecs(), + "opacity.keys", + "Add Opacity Key", + {_ui->opacityStartSpin->value()}); + connectTableButtons(_ui->sizeAddButton, + _ui->sizeRemoveButton, + _ui->sizeSortButton, + _ui->sizeKeysTable, + ScalarKeySpecs(), + "size.keys", + "Add Size Key", + {_ui->sizeStartSpin->value()}); + connectTableButtons(_ui->rotationAddButton, + _ui->rotationRemoveButton, + _ui->rotationSortButton, + _ui->rotationKeysTable, + ScalarKeySpecs(), + "rotation.keys", + "Add Rotation Key", + {_ui->rotationStartSpin->value()}); + connectTableButtons(_ui->frameAddButton, + _ui->frameRemoveButton, + _ui->frameSortButton, + _ui->frameKeysTable, + ScalarKeySpecs(), + "frame.keys", + "Add Frame / U Key", + {_ui->frameStartSpin->value()}); + connectTableButtons(_ui->blurAddButton, + _ui->blurRemoveButton, + _ui->blurSortButton, + _ui->blurKeysTable, + ScalarKeySpecs(), + "blur.keys", + "Add Blur-Time Key", + {_ui->blurStartSpin->value()}); +} + +void EmitterEditDialog::connectTableEditors(QTableWidget *table, const QString &dirtyKey) +{ + for (int row = 0; row < table->rowCount(); ++row) { + for (int column = 0; column < table->columnCount(); ++column) { + auto *spin = qobject_cast(table->cellWidget(row, column)); + if (!spin || spin->property("emitterDirtyConnected").toBool()) { + continue; + } + spin->setProperty("emitterDirtyConnected", true); + connect(spin, + qOverload(&QDoubleSpinBox::valueChanged), + this, + [this, dirtyKey]() { markDirty(dirtyKey); }); + } + } +} + +bool EmitterEditDialog::updateDefinitionFromUi() +{ + const QString name = _ui->nameEdit->text(); + if (name.isEmpty()) { + QMessageBox::warning(this, "Emitter", "Invalid emitter name. Please enter a name."); + return false; + } + + const float oldLifetime = _definition.Get_Lifetime(); + const float newLifetime = _ui->useLifetimeCheck->isChecked() + ? static_cast(_ui->lifetimeSpin->value()) + : 5000000.0f; + const bool lifetimeChanged = isDirty("general.lifetime") && newLifetime != oldLifetime; + + if (isDirty("general.name")) { + const QByteArray bytes = name.toLatin1(); + _definition.Set_Name(bytes.constData()); + } + if (isDirty("general.texture")) { + const QByteArray bytes = _ui->textureEdit->text().toLatin1(); + _definition.Set_Texture_Filename(bytes.constData()); + } + if (isDirty("general.lifetime")) { + _definition.Set_Lifetime(newLifetime); + } + if (isDirty("general.shader")) { + const int presetIndex = _ui->shaderCombo->currentData().toInt(); + int presetCount = 0; + const ShaderPreset *presets = ShaderPresets(presetCount); + if (presetIndex >= 0 && presetIndex < presetCount) { + _definition.Set_Shader(presets[presetIndex].shader); + } + } + if (isDirty("general.renderMode")) { + _definition.Set_Render_Mode(_ui->renderModeCombo->currentData().toInt()); + } + + if (isDirty("particle.rate")) { + _definition.Set_Emission_Rate(static_cast(_ui->emissionRateSpin->value())); + } + if (isDirty("particle.burst")) { + _definition.Set_Burst_Size(static_cast(_ui->burstSizeSpin->value())); + } + if (isDirty("particle.max")) { + _definition.Set_Max_Emissions(_ui->limitParticlesCheck->isChecked() + ? static_cast(_ui->maxParticlesSpin->value()) + : 0.0f); + } + if (isDirty("particle.fade")) { + _definition.Set_Fade_Time(static_cast(_ui->fadeTimeSpin->value())); + } + if (isDirty("particle.creation")) { + if (Vector3Randomizer *randomizer = randomizerFromUi(_ui->creationTypeCombo, + _ui->creationValue1Spin, + _ui->creationValue2Spin, + _ui->creationValue3Spin)) { + _definition.Set_Creation_Volume(randomizer); + } + } + + Vector3 velocity = _definition.Get_Velocity(); + bool velocityChanged = false; + if (isDirty("physics.velocity.x")) { + velocity.X = static_cast(_ui->velocityXSpin->value()); + velocityChanged = true; + } + if (isDirty("physics.velocity.y")) { + velocity.Y = static_cast(_ui->velocityYSpin->value()); + velocityChanged = true; + } + if (isDirty("physics.velocity.z")) { + velocity.Z = static_cast(_ui->velocityZSpin->value()); + velocityChanged = true; + } + if (velocityChanged) { + _definition.Set_Velocity(velocity); + } + + if (isDirty("physics.randomizer")) { + if (Vector3Randomizer *randomizer = randomizerFromUi(_ui->velocityRandomTypeCombo, + _ui->velocityRandomValue1Spin, + _ui->velocityRandomValue2Spin, + _ui->velocityRandomValue3Spin)) { + _definition.Set_Velocity_Random(randomizer); + } + } + + Vector3 acceleration = _definition.Get_Acceleration(); + bool accelerationChanged = false; + if (isDirty("physics.acceleration.x")) { + acceleration.X = static_cast(_ui->accelXSpin->value()); + accelerationChanged = true; + } + if (isDirty("physics.acceleration.y")) { + acceleration.Y = static_cast(_ui->accelYSpin->value()); + accelerationChanged = true; + } + if (isDirty("physics.acceleration.z")) { + acceleration.Z = static_cast(_ui->accelZSpin->value()); + accelerationChanged = true; + } + if (accelerationChanged) { + _definition.Set_Acceleration(acceleration); + } + if (isDirty("physics.outward")) { + _definition.Set_Outward_Vel(static_cast(_ui->outwardVelSpin->value())); + } + if (isDirty("physics.inherit")) { + _definition.Set_Vel_Inherit(static_cast(_ui->inheritVelSpin->value())); + } + if (isDirty("physics.gravity")) { + _definition.Set_Gravity(static_cast(_ui->gravitySpin->value())); + } + if (isDirty("physics.elasticity")) { + _definition.Set_Elasticity(static_cast(_ui->elasticitySpin->value())); + } + + applyColorKeyframes(); + applyOpacityKeyframes(); + applySizeKeyframes(); + + if (isDirty("user.string")) { + const QByteArray bytes = _ui->userStringEdit->toPlainText().toLatin1(); + _definition.Set_User_String(bytes.constData()); + } + if (isDirty("user.type")) { + _definition.Set_User_Type(_ui->userTypeCombo->currentData().toInt()); + } + + if (isDirty("line.mapping")) { + _definition.Set_Line_Texture_Mapping_Mode(_ui->lineMappingCombo->currentData().toInt()); + } + if (isDirty("line.merge")) { + _definition.Set_Merge_Intersections(_ui->lineMergeCheck->isChecked()); + } + if (isDirty("line.freeze")) { + _definition.Set_Freeze_Random(_ui->lineFreezeCheck->isChecked()); + } + if (isDirty("line.sorting")) { + _definition.Set_Disable_Sorting(_ui->lineDisableSortingCheck->isChecked()); + } + if (isDirty("line.endCaps")) { + _definition.Set_End_Caps(_ui->lineEndCapsCheck->isChecked()); + } + if (isDirty("line.subdivision")) { + _definition.Set_Subdivision_Level(_ui->lineSubdivisionSpin->value()); + } + if (isDirty("line.noise")) { + _definition.Set_Noise_Amplitude(static_cast(_ui->lineNoiseSpin->value())); + } + if (isDirty("line.mergeAbort")) { + _definition.Set_Merge_Abort_Factor(static_cast(_ui->lineMergeAbortSpin->value())); + } + if (isDirty("line.tile")) { + _definition.Set_Texture_Tile_Factor(static_cast(_ui->lineTileSpin->value())); + } + Vector2 uvRate = _definition.Get_UV_Offset_Rate(); + bool uvChanged = false; + if (isDirty("line.u")) { + uvRate.X = static_cast(_ui->lineUSpin->value()); + uvChanged = true; + } + if (isDirty("line.v")) { + uvRate.Y = static_cast(_ui->lineVSpin->value()); + uvChanged = true; + } + if (uvChanged) { + _definition.Set_UV_Offset_Rate(uvRate); + } + + applyRotationKeyframes(); + applyFrameKeyframes(); + applyBlurTimeKeyframes(); + if (lifetimeChanged) { + rescaleKeyframeTimes(oldLifetime, newLifetime); + } + + return true; +} + +bool EmitterEditDialog::commitPendingChanges() +{ + const bool hasPendingChanges = _initialApplyRequired || !_dirtyFields.isEmpty(); + if (!updateDefinitionFromUi()) { + return false; + } + + if (!hasPendingChanges) { + return true; + } + + if (_applyHandler && !_applyHandler(_definition, _registeredName)) { + return false; + } + + if (const char *name = _definition.Get_Name()) { + _registeredName = QString::fromLatin1(name); + } + _dirtyFields.clear(); + _initialApplyRequired = false; + updateApplyButton(); + return true; +} + +void EmitterEditDialog::apply() +{ + commitPendingChanges(); +} + +void EmitterEditDialog::accept() +{ + if (!commitPendingChanges()) { + return; + } + + QDialog::accept(); +} + +void EmitterEditDialog::browseTexture() +{ + const QString path = QFileDialog::getOpenFileName(this, + "Select Texture", + _ui->textureEdit->text(), + "Texture Files (*.tga *.dds *.png *.jpg *.jpeg);;All Files (*.*)"); + if (!path.isEmpty()) { + _ui->textureEdit->setText(path); + markDirty("general.texture"); + } +} + +void EmitterEditDialog::toggleLifetime(bool enabled) +{ + _ui->lifetimeSpin->setEnabled(enabled); +} + +void EmitterEditDialog::toggleMaxParticles(bool enabled) +{ + _ui->maxParticlesSpin->setEnabled(enabled); +} + +void EmitterEditDialog::loadFromDefinition() +{ + if (const char *name = _definition.Get_Name()) { + _ui->nameEdit->setText(QString::fromLatin1(name)); + _originalName = QString::fromLatin1(name); + } + if (const char *texture = _definition.Get_Texture_Filename()) { + _ui->textureEdit->setText(QString::fromLatin1(texture)); + } + + const float lifetime = _definition.Get_Lifetime(); + const bool useLifetime = lifetime < 100.0f; + _ui->useLifetimeCheck->setChecked(useLifetime); + _ui->lifetimeSpin->setEnabled(useLifetime); + _ui->lifetimeSpin->setValue(useLifetime ? lifetime : 0.0); + + int shaderIndex = findShaderIndex(); + if (shaderIndex < 0) { + _ui->shaderCombo->addItem("Custom (preserved)", -1); + shaderIndex = _ui->shaderCombo->count() - 1; + } + _ui->shaderCombo->setCurrentIndex(shaderIndex); + + const auto selectData = [](QComboBox *combo, int value, const QString &customLabel) { + int index = combo->findData(value); + if (index < 0) { + combo->addItem(customLabel.arg(value), value); + index = combo->count() - 1; + } + combo->setCurrentIndex(index); + }; + selectData(_ui->renderModeCombo, _definition.Get_Render_Mode(), "Custom (%1)"); + selectData(_ui->frameModeCombo, _definition.Get_Frame_Mode(), "Custom (%1)"); + + _ui->emissionRateSpin->setValue(_definition.Get_Emission_Rate()); + _ui->burstSizeSpin->setValue(_definition.Get_Burst_Size()); + const float maxEmissions = _definition.Get_Max_Emissions(); + const bool limitParticles = maxEmissions != 0.0f; + _ui->limitParticlesCheck->setChecked(limitParticles); + _ui->maxParticlesSpin->setEnabled(limitParticles); + _ui->maxParticlesSpin->setValue(limitParticles ? maxEmissions : 0.0f); + _ui->fadeTimeSpin->setValue(_definition.Get_Fade_Time()); + + loadRandomizer(_definition.Get_Creation_Volume(), + _ui->creationTypeCombo, + _ui->creationValue1Spin, + _ui->creationValue2Spin, + _ui->creationValue3Spin); + updateRandomizerControls(_ui->creationTypeCombo, + _ui->creationValue1Label, + _ui->creationValue2Label, + _ui->creationValue3Label, + _ui->creationValue1Spin, + _ui->creationValue2Spin, + _ui->creationValue3Spin); + + const Vector3 velocity = _definition.Get_Velocity(); + _ui->velocityXSpin->setValue(velocity.X); + _ui->velocityYSpin->setValue(velocity.Y); + _ui->velocityZSpin->setValue(velocity.Z); + loadRandomizer(_definition.Get_Velocity_Random(), + _ui->velocityRandomTypeCombo, + _ui->velocityRandomValue1Spin, + _ui->velocityRandomValue2Spin, + _ui->velocityRandomValue3Spin); + updateRandomizerControls(_ui->velocityRandomTypeCombo, + _ui->velocityRandomValue1Label, + _ui->velocityRandomValue2Label, + _ui->velocityRandomValue3Label, + _ui->velocityRandomValue1Spin, + _ui->velocityRandomValue2Spin, + _ui->velocityRandomValue3Spin); + + const Vector3 acceleration = _definition.Get_Acceleration(); + _ui->accelXSpin->setValue(acceleration.X); + _ui->accelYSpin->setValue(acceleration.Y); + _ui->accelZSpin->setValue(acceleration.Z); + _ui->outwardVelSpin->setValue(_definition.Get_Outward_Vel()); + _ui->inheritVelSpin->setValue(_definition.Get_Vel_Inherit()); + _ui->gravitySpin->setValue(_definition.Get_Gravity()); + _ui->elasticitySpin->setValue(_definition.Get_Elasticity()); + + ParticlePropertyStruct colors{}; + _definition.Get_Color_Keyframes(colors); + _ui->colorStartRSpin->setValue(colors.Start.X); + _ui->colorStartGSpin->setValue(colors.Start.Y); + _ui->colorStartBSpin->setValue(colors.Start.Z); + _ui->colorRandomRSpin->setValue(colors.Rand.X); + _ui->colorRandomGSpin->setValue(colors.Rand.Y); + _ui->colorRandomBSpin->setValue(colors.Rand.Z); + QVector> colorRows; + colorRows.reserve(static_cast(colors.NumKeyFrames)); + for (unsigned int index = 0; index < colors.NumKeyFrames; ++index) { + colorRows.push_back({colors.KeyTimes[index], colors.Values[index].X, colors.Values[index].Y, colors.Values[index].Z}); + } + SetKeyframeRows(_ui->colorKeysTable, colorRows, ColorKeySpecs()); + FreeProperty(colors); + + ParticlePropertyStruct opacity{}; + _definition.Get_Opacity_Keyframes(opacity); + _ui->opacityStartSpin->setValue(opacity.Start); + _ui->opacityRandomSpin->setValue(opacity.Rand); + QVector> opacityRows; + opacityRows.reserve(static_cast(opacity.NumKeyFrames)); + for (unsigned int index = 0; index < opacity.NumKeyFrames; ++index) { + opacityRows.push_back({opacity.KeyTimes[index], opacity.Values[index]}); + } + SetKeyframeRows(_ui->opacityKeysTable, opacityRows, OpacityKeySpecs()); + FreeProperty(opacity); + + ParticlePropertyStruct size{}; + _definition.Get_Size_Keyframes(size); + _ui->sizeStartSpin->setValue(size.Start); + _ui->sizeRandomSpin->setValue(size.Rand); + QVector> sizeRows; + sizeRows.reserve(static_cast(size.NumKeyFrames)); + for (unsigned int index = 0; index < size.NumKeyFrames; ++index) { + sizeRows.push_back({size.KeyTimes[index], size.Values[index]}); + } + SetKeyframeRows(_ui->sizeKeysTable, sizeRows, ScalarKeySpecs()); + FreeProperty(size); + + const char *userString = _definition.Get_User_String(); + _ui->userStringEdit->setPlainText(userString ? QString::fromLatin1(userString) : QString()); + selectData(_ui->userTypeCombo, _definition.Get_User_Type(), "Custom (%1)"); + + selectData(_ui->lineMappingCombo, _definition.Get_Line_Texture_Mapping_Mode(), "Custom (%1)"); + _ui->lineMergeCheck->setChecked(_definition.Is_Merge_Intersections() != 0); + _ui->lineFreezeCheck->setChecked(_definition.Is_Freeze_Random() != 0); + _ui->lineDisableSortingCheck->setChecked(_definition.Is_Sorting_Disabled() != 0); + _ui->lineEndCapsCheck->setChecked(_definition.Are_End_Caps_Enabled() != 0); + _ui->lineSubdivisionSpin->setValue(_definition.Get_Subdivision_Level()); + _ui->lineNoiseSpin->setValue(_definition.Get_Noise_Amplitude()); + _ui->lineMergeAbortSpin->setValue(_definition.Get_Merge_Abort_Factor()); + _ui->lineTileSpin->setValue(_definition.Get_Texture_Tile_Factor()); + const Vector2 uvRate = _definition.Get_UV_Offset_Rate(); + _ui->lineUSpin->setValue(uvRate.X); + _ui->lineVSpin->setValue(uvRate.Y); + + ParticlePropertyStruct rotation{}; + _definition.Get_Rotation_Keyframes(rotation); + _ui->rotationStartSpin->setValue(rotation.Start); + _ui->rotationRandomSpin->setValue(rotation.Rand); + _ui->orientationRandomSpin->setValue(_definition.Get_Initial_Orientation_Random()); + QVector> rotationRows; + rotationRows.reserve(static_cast(rotation.NumKeyFrames)); + for (unsigned int index = 0; index < rotation.NumKeyFrames; ++index) { + rotationRows.push_back({rotation.KeyTimes[index], rotation.Values[index]}); + } + SetKeyframeRows(_ui->rotationKeysTable, rotationRows, ScalarKeySpecs()); + FreeProperty(rotation); + + ParticlePropertyStruct frames{}; + _definition.Get_Frame_Keyframes(frames); + _ui->frameStartSpin->setValue(frames.Start); + _ui->frameRandomSpin->setValue(frames.Rand); + QVector> frameRows; + frameRows.reserve(static_cast(frames.NumKeyFrames)); + for (unsigned int index = 0; index < frames.NumKeyFrames; ++index) { + frameRows.push_back({frames.KeyTimes[index], frames.Values[index]}); + } + SetKeyframeRows(_ui->frameKeysTable, frameRows, ScalarKeySpecs()); + FreeProperty(frames); + + ParticlePropertyStruct blurTimes{}; + _definition.Get_Blur_Time_Keyframes(blurTimes); + _ui->blurStartSpin->setValue(blurTimes.Start); + _ui->blurRandomSpin->setValue(blurTimes.Rand); + QVector> blurRows; + blurRows.reserve(static_cast(blurTimes.NumKeyFrames)); + for (unsigned int index = 0; index < blurTimes.NumKeyFrames; ++index) { + blurRows.push_back({blurTimes.KeyTimes[index], blurTimes.Values[index]}); + } + SetKeyframeRows(_ui->blurKeysTable, blurRows, ScalarKeySpecs()); + FreeProperty(blurTimes); +} + +void EmitterEditDialog::loadRandomizer(Vector3Randomizer *randomizer, + QComboBox *typeCombo, + QDoubleSpinBox *value1, + QDoubleSpinBox *value2, + QDoubleSpinBox *value3) +{ + std::unique_ptr owned(randomizer); + if (!owned) { + return; + } + + const int classId = static_cast(owned->Class_ID()); + int index = typeCombo->findData(classId); + if (index < 0) { + typeCombo->addItem(QString("Custom (%1, preserved)").arg(classId), classId); + index = typeCombo->count() - 1; + } + typeCombo->setCurrentIndex(index); + + switch (owned->Class_ID()) { + case Vector3Randomizer::CLASSID_SOLIDBOX: { + const Vector3 extents = static_cast(owned.get())->Get_Extents(); + value1->setValue(extents.X); + value2->setValue(extents.Y); + value3->setValue(extents.Z); + break; + } + case Vector3Randomizer::CLASSID_SOLIDSPHERE: + value1->setValue(static_cast(owned.get())->Get_Radius()); + break; + case Vector3Randomizer::CLASSID_HOLLOWSPHERE: + value1->setValue(static_cast(owned.get())->Get_Radius()); + break; + case Vector3Randomizer::CLASSID_SOLIDCYLINDER: + value1->setValue(static_cast(owned.get())->Get_Height()); + value2->setValue(static_cast(owned.get())->Get_Radius()); + break; + default: + break; + } +} + +void EmitterEditDialog::updateRandomizerControls(QComboBox *typeCombo, + QLabel *value1Label, + QLabel *value2Label, + QLabel *value3Label, + QDoubleSpinBox *value1, + QDoubleSpinBox *value2, + QDoubleSpinBox *value3) const +{ + const int classId = typeCombo->currentData().toInt(); + QString label1 = "Value 1:"; + QString label2 = "Value 2:"; + QString label3 = "Value 3:"; + bool enable1 = true; + bool enable2 = true; + bool enable3 = true; + + switch (classId) { + case Vector3Randomizer::CLASSID_SOLIDBOX: + label1 = "X extent:"; + label2 = "Y extent:"; + label3 = "Z extent:"; + break; + case Vector3Randomizer::CLASSID_SOLIDSPHERE: + case Vector3Randomizer::CLASSID_HOLLOWSPHERE: + label1 = "Radius:"; + label2.clear(); + label3.clear(); + enable2 = false; + enable3 = false; + break; + case Vector3Randomizer::CLASSID_SOLIDCYLINDER: + label1 = "Height:"; + label2 = "Radius:"; + label3.clear(); + enable3 = false; + break; + default: + label1 = "Custom data is preserved until a known type is selected."; + label2.clear(); + label3.clear(); + enable1 = false; + enable2 = false; + enable3 = false; + break; + } + + value1Label->setText(label1); + value2Label->setText(label2); + value3Label->setText(label3); + value1->setEnabled(enable1); + value2->setEnabled(enable2); + value3->setEnabled(enable3); + value2Label->setVisible(enable2); + value2->setVisible(enable2); + value3Label->setVisible(enable3); + value3->setVisible(enable3); +} + +Vector3Randomizer *EmitterEditDialog::randomizerFromUi(QComboBox *typeCombo, + QDoubleSpinBox *value1, + QDoubleSpinBox *value2, + QDoubleSpinBox *value3) const +{ + const int classId = typeCombo->currentData().toInt(); + const float first = static_cast(value1->value()); + const float second = static_cast(value2->value()); + const float third = static_cast(value3->value()); + switch (classId) { + case Vector3Randomizer::CLASSID_SOLIDBOX: + return new Vector3SolidBoxRandomizer(Vector3(first, second, third)); + case Vector3Randomizer::CLASSID_SOLIDSPHERE: + return new Vector3SolidSphereRandomizer(first); + case Vector3Randomizer::CLASSID_HOLLOWSPHERE: + return new Vector3HollowSphereRandomizer(first); + case Vector3Randomizer::CLASSID_SOLIDCYLINDER: + return new Vector3SolidCylinderRandomizer(first, second); + default: + return nullptr; + } +} + +void EmitterEditDialog::updateRenderModeTabs() +{ + const int mode = _ui->renderModeCombo->currentData().toInt(); + _ui->tabWidget->setTabEnabled(_ui->tabWidget->indexOf(_ui->lineTab), mode == W3D_EMITTER_RENDER_MODE_LINE); + const bool lineGroup = mode == W3D_EMITTER_RENDER_MODE_LINEGRP_TETRA || + mode == W3D_EMITTER_RENDER_MODE_LINEGRP_PRISM; + _ui->tabWidget->setTabEnabled(_ui->tabWidget->indexOf(_ui->lineGroupTab), lineGroup); +} + +void EmitterEditDialog::applyColorKeyframes() +{ + const QStringList keys = {"color.start.r", "color.start.g", "color.start.b", "color.random.r", + "color.random.g", "color.random.b", "color.keys"}; + bool changed = false; + for (const QString &key : keys) { + changed |= isDirty(key); + } + if (!changed) { + return; + } + + ParticlePropertyStruct property{}; + _definition.Get_Color_Keyframes(property); + if (isDirty("color.start.r")) property.Start.X = static_cast(_ui->colorStartRSpin->value()); + if (isDirty("color.start.g")) property.Start.Y = static_cast(_ui->colorStartGSpin->value()); + if (isDirty("color.start.b")) property.Start.Z = static_cast(_ui->colorStartBSpin->value()); + if (isDirty("color.random.r")) property.Rand.X = static_cast(_ui->colorRandomRSpin->value()); + if (isDirty("color.random.g")) property.Rand.Y = static_cast(_ui->colorRandomGSpin->value()); + if (isDirty("color.random.b")) property.Rand.Z = static_cast(_ui->colorRandomBSpin->value()); + if (isDirty("color.keys")) ReplaceVectorKeys(property, _ui->colorKeysTable); + _definition.Set_Color_Keyframes(property); + FreeProperty(property); +} + +void EmitterEditDialog::applyOpacityKeyframes() +{ + if (!isDirty("opacity.start") && !isDirty("opacity.random") && !isDirty("opacity.keys")) return; + ParticlePropertyStruct property{}; + _definition.Get_Opacity_Keyframes(property); + if (isDirty("opacity.start")) property.Start = static_cast(_ui->opacityStartSpin->value()); + if (isDirty("opacity.random")) property.Rand = static_cast(_ui->opacityRandomSpin->value()); + if (isDirty("opacity.keys")) ReplaceScalarKeys(property, _ui->opacityKeysTable); + _definition.Set_Opacity_Keyframes(property); + FreeProperty(property); +} + +void EmitterEditDialog::applySizeKeyframes() +{ + if (!isDirty("size.start") && !isDirty("size.random") && !isDirty("size.keys")) return; + ParticlePropertyStruct property{}; + _definition.Get_Size_Keyframes(property); + if (isDirty("size.start")) property.Start = static_cast(_ui->sizeStartSpin->value()); + if (isDirty("size.random")) property.Rand = static_cast(_ui->sizeRandomSpin->value()); + if (isDirty("size.keys")) ReplaceScalarKeys(property, _ui->sizeKeysTable); + _definition.Set_Size_Keyframes(property); + FreeProperty(property); +} + +void EmitterEditDialog::applyRotationKeyframes() +{ + const bool propertyChanged = isDirty("rotation.start") || isDirty("rotation.random") || isDirty("rotation.keys"); + const bool orientationChanged = isDirty("rotation.orientationRandom"); + if (!propertyChanged && !orientationChanged) return; + ParticlePropertyStruct property{}; + _definition.Get_Rotation_Keyframes(property); + if (isDirty("rotation.start")) property.Start = static_cast(_ui->rotationStartSpin->value()); + if (isDirty("rotation.random")) property.Rand = static_cast(_ui->rotationRandomSpin->value()); + if (isDirty("rotation.keys")) ReplaceScalarKeys(property, _ui->rotationKeysTable); + const float orientation = orientationChanged + ? static_cast(_ui->orientationRandomSpin->value()) + : _definition.Get_Initial_Orientation_Random(); + _definition.Set_Rotation_Keyframes(property, orientation); + FreeProperty(property); +} + +void EmitterEditDialog::applyFrameKeyframes() +{ + if (isDirty("frame.mode")) { + _definition.Set_Frame_Mode(_ui->frameModeCombo->currentData().toInt()); + } + if (!isDirty("frame.start") && !isDirty("frame.random") && !isDirty("frame.keys")) return; + ParticlePropertyStruct property{}; + _definition.Get_Frame_Keyframes(property); + if (isDirty("frame.start")) property.Start = static_cast(_ui->frameStartSpin->value()); + if (isDirty("frame.random")) property.Rand = static_cast(_ui->frameRandomSpin->value()); + if (isDirty("frame.keys")) ReplaceScalarKeys(property, _ui->frameKeysTable); + _definition.Set_Frame_Keyframes(property); + FreeProperty(property); +} + +void EmitterEditDialog::applyBlurTimeKeyframes() +{ + if (!isDirty("blur.start") && !isDirty("blur.random") && !isDirty("blur.keys")) return; + ParticlePropertyStruct property{}; + _definition.Get_Blur_Time_Keyframes(property); + if (isDirty("blur.start")) property.Start = static_cast(_ui->blurStartSpin->value()); + if (isDirty("blur.random")) property.Rand = static_cast(_ui->blurRandomSpin->value()); + if (isDirty("blur.keys")) ReplaceScalarKeys(property, _ui->blurKeysTable); + _definition.Set_Blur_Time_Keyframes(property); + FreeProperty(property); +} + +void EmitterEditDialog::rescaleKeyframeTimes(float oldLifetime, float newLifetime) +{ + if (oldLifetime == 0.0f) { + return; + } + const float conversion = newLifetime / oldLifetime; + + ScaleTableKeyTimes(_ui->colorKeysTable, conversion); + ScaleTableKeyTimes(_ui->opacityKeysTable, conversion); + ScaleTableKeyTimes(_ui->sizeKeysTable, conversion); + ScaleTableKeyTimes(_ui->rotationKeysTable, conversion); + ScaleTableKeyTimes(_ui->frameKeysTable, conversion); + ScaleTableKeyTimes(_ui->blurKeysTable, conversion); + + ParticlePropertyStruct colors{}; + _definition.Get_Color_Keyframes(colors); + for (unsigned int index = 0; index < colors.NumKeyFrames; ++index) colors.KeyTimes[index] *= conversion; + _definition.Set_Color_Keyframes(colors); + FreeProperty(colors); + + ParticlePropertyStruct opacity{}; + _definition.Get_Opacity_Keyframes(opacity); + for (unsigned int index = 0; index < opacity.NumKeyFrames; ++index) opacity.KeyTimes[index] *= conversion; + _definition.Set_Opacity_Keyframes(opacity); + FreeProperty(opacity); + + ParticlePropertyStruct size{}; + _definition.Get_Size_Keyframes(size); + for (unsigned int index = 0; index < size.NumKeyFrames; ++index) size.KeyTimes[index] *= conversion; + _definition.Set_Size_Keyframes(size); + FreeProperty(size); + + ParticlePropertyStruct rotation{}; + _definition.Get_Rotation_Keyframes(rotation); + for (unsigned int index = 0; index < rotation.NumKeyFrames; ++index) rotation.KeyTimes[index] *= conversion; + _definition.Set_Rotation_Keyframes(rotation, _definition.Get_Initial_Orientation_Random()); + FreeProperty(rotation); + + ParticlePropertyStruct frames{}; + _definition.Get_Frame_Keyframes(frames); + for (unsigned int index = 0; index < frames.NumKeyFrames; ++index) frames.KeyTimes[index] *= conversion; + _definition.Set_Frame_Keyframes(frames); + FreeProperty(frames); + + ParticlePropertyStruct blurTimes{}; + _definition.Get_Blur_Time_Keyframes(blurTimes); + for (unsigned int index = 0; index < blurTimes.NumKeyFrames; ++index) blurTimes.KeyTimes[index] *= conversion; + _definition.Set_Blur_Time_Keyframes(blurTimes); + FreeProperty(blurTimes); +} + +void EmitterEditDialog::markDirty(const QString &key) +{ + _dirtyFields.insert(key); + updateApplyButton(); +} + +void EmitterEditDialog::updateApplyButton() +{ + if (!_ui || !_ui->buttonBox) { + return; + } + + if (QPushButton *applyButton = _ui->buttonBox->button(QDialogButtonBox::Apply)) { + applyButton->setEnabled(_initialApplyRequired || !_dirtyFields.isEmpty()); + } +} + +bool EmitterEditDialog::isDirty(const QString &key) const +{ + return _dirtyFields.contains(key); +} + +int EmitterEditDialog::findShaderIndex() const +{ + ShaderClass current; + _definition.Get_Shader(current); + + int presetCount = 0; + const ShaderPreset *presets = ShaderPresets(presetCount); + for (int index = 0; index < presetCount; ++index) { + if (ShaderMatches(current, presets[index].shader)) { + return index; + } + } + return -1; +} diff --git a/Code/Tools/W3DViewQt/EmitterEditDialog.h b/Code/Tools/W3DViewQt/EmitterEditDialog.h new file mode 100644 index 000000000..1db996b2a --- /dev/null +++ b/Code/Tools/W3DViewQt/EmitterEditDialog.h @@ -0,0 +1,87 @@ +#pragma once + +#include "part_ldr.h" + +#include +#include +#include +#include + +class QComboBox; +class QDoubleSpinBox; +class QLabel; +class QTableWidget; + +namespace Ui { +class EmitterEditDialog; +} + +class EmitterEditDialog final : public QDialog +{ + Q_OBJECT + +public: + using ApplyHandler = std::function; + + explicit EmitterEditDialog(const ParticleEmitterDefClass &definition, QWidget *parent = nullptr); + ~EmitterEditDialog() override; + + ParticleEmitterDefClass *definition() const; + QString originalName() const; + void setApplyHandler(ApplyHandler handler, + const QString ®isteredName, + bool initialApplyRequired = false); + +protected: + void accept() override; + +private slots: + void apply(); + void browseTexture(); + void toggleLifetime(bool enabled); + void toggleMaxParticles(bool enabled); + +private: + void configureControls(); + void connectDirtyTracking(); + void connectTableEditors(QTableWidget *table, const QString &dirtyKey); + void loadFromDefinition(); + void loadRandomizer(Vector3Randomizer *randomizer, + QComboBox *typeCombo, + QDoubleSpinBox *value1, + QDoubleSpinBox *value2, + QDoubleSpinBox *value3); + void updateRandomizerControls(QComboBox *typeCombo, + QLabel *value1Label, + QLabel *value2Label, + QLabel *value3Label, + QDoubleSpinBox *value1, + QDoubleSpinBox *value2, + QDoubleSpinBox *value3) const; + Vector3Randomizer *randomizerFromUi(QComboBox *typeCombo, + QDoubleSpinBox *value1, + QDoubleSpinBox *value2, + QDoubleSpinBox *value3) const; + void updateRenderModeTabs(); + void applyColorKeyframes(); + void applyOpacityKeyframes(); + void applySizeKeyframes(); + void applyRotationKeyframes(); + void applyFrameKeyframes(); + void applyBlurTimeKeyframes(); + void rescaleKeyframeTimes(float oldLifetime, float newLifetime); + bool updateDefinitionFromUi(); + bool commitPendingChanges(); + void updateApplyButton(); + void markDirty(const QString &key); + bool isDirty(const QString &key) const; + int findShaderIndex() const; + + ParticleEmitterDefClass _definition; + QString _originalName; + QString _registeredName; + QSet _dirtyFields; + ApplyHandler _applyHandler; + bool _initialApplyRequired = false; + Ui::EmitterEditDialog *_ui = nullptr; +}; diff --git a/Code/Tools/W3DViewQt/EmitterEditDialog.ui b/Code/Tools/W3DViewQt/EmitterEditDialog.ui new file mode 100644 index 000000000..ab1ecfe1c --- /dev/null +++ b/Code/Tools/W3DViewQt/EmitterEditDialog.ui @@ -0,0 +1,241 @@ + + + EmitterEditDialog + + + 00960720 + + + 760560 + + + Emitter Properties + + + + + 0 + + General + + QFormLayout::AllNonFixedFieldsGrow + Name: + + Particle lifetime: + + + + 0000 + Use lifetime + s + + + + Shader: + + Rendering mode: + + Texture: + + + + 0000 + + Browse... + + + + + Qt::Vertical20240 + + + + + Particle + + + Emission + + Emission rate: + + Burst size: + + Maximum particles: + + + + 0000 + Limit + + + + + Fade time: + s + + + + + Creation volume + + Shape: + + Value 1: + + Value 2: + + Value 3: + + + + + Qt::Vertical20120 + + + + Physics + + + Starting velocity + + Velocity: + + + + 0000 + X + Y + Z + + + + Randomizer: + + Value 1: + + Value 2: + + Value 3: + + + + + + Motion + + Acceleration: + + + + 0000 + X + Y + Z + + + + Outward velocity: + + Velocity inheritance: + + Gravity: + + Elasticity: + + + + + + + + Color + + + Color keyframes + + + + Starting color: + 0000RGB + Randomizer: + 0000RGB + + + 0130 + Qt::Horizontal4020Add...RemoveSort + + + + + Opacity keyframes + + Starting opacity:Randomizer: + 0130 + Qt::Horizontal4020Add...RemoveSort + + + + + + + Size + + Starting size:Randomizer: + + Qt::Horizontal4020Add...RemoveSort + + + + User + + Type:Programmer settings:User data is preserved exactly, including whitespace and line breaks. + + + + Line + + Texture mapping: + Flags: + 0000Merge intersectionsFreeze randomKeep random line offsets fixed in camera space.Disable sortingEnd caps + Subdivision level: + Noise amplitude: + Merge abort factor: + Texture tile factor: + U offset / second: + V offset / second: + Qt::Vertical20120 + + + + Rotation + + Starting rotational velocity: rotations/sVelocity randomizer: rotations/sInitial orientation randomizer: rotations + + Qt::Horizontal4020Add...RemoveSort + + + + Frame + + Texture grid layout:Starting frame / U:Randomizer: + + Qt::Horizontal4020Add...RemoveSort + + + + Line Group + + Blur-time keyframes control the length of line-group particle trails.true + Starting blur time:Randomizer: + + Qt::Horizontal4020Add...RemoveSort + + + + + QDialogButtonBox::Apply|QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + diff --git a/Code/Tools/W3DViewQt/ExportDirectoryDialog.cpp b/Code/Tools/W3DViewQt/ExportDirectoryDialog.cpp new file mode 100644 index 000000000..b7d04ac04 --- /dev/null +++ b/Code/Tools/W3DViewQt/ExportDirectoryDialog.cpp @@ -0,0 +1,96 @@ +#include "ExportDirectoryDialog.h" + +#include "ui_ExportDirectoryDialog.h" + +#include +#include +#include +#include +#include + +ExportDirectoryDialog::ExportDirectoryDialog(const QString &fixedFilename, QWidget *parent) + : ExportDirectoryDialog(fixedFilename, QDir::currentPath(), parent) +{ +} + +ExportDirectoryDialog::ExportDirectoryDialog(const QString &fixedFilename, + const QString &initialDirectory, + QWidget *parent) + : QDialog(parent) + , _ui(new Ui::ExportDirectoryDialog) + , _fixedFilename(fixedFilename) +{ + _ui->setupUi(this); + _ui->filenameEdit->setText(_fixedFilename); + _ui->directoryEdit->setText(QDir::toNativeSeparators(initialDirectory)); + + connect(_ui->directoryEdit, + &QLineEdit::textChanged, + this, + &ExportDirectoryDialog::updateOkButton); + connect(_ui->browseButton, + &QPushButton::clicked, + this, + &ExportDirectoryDialog::browse); + connect(_ui->buttonBox, + &QDialogButtonBox::accepted, + this, + &ExportDirectoryDialog::accept); + connect(_ui->buttonBox, + &QDialogButtonBox::rejected, + this, + &QDialog::reject); + + updateOkButton(); +} + +ExportDirectoryDialog::~ExportDirectoryDialog() +{ + delete _ui; +} + +QString ExportDirectoryDialog::selectedPath() const +{ + const QString selectedDirectory = directory(); + if (selectedDirectory.isEmpty()) { + return {}; + } + return QDir(selectedDirectory).filePath(_fixedFilename); +} + +void ExportDirectoryDialog::accept() +{ + if (!directory().isEmpty() && QDir(directory()).exists()) { + QDialog::accept(); + } +} + +void ExportDirectoryDialog::browse() +{ + QString initialDirectory = directory(); + if (!QDir(initialDirectory).exists()) { + initialDirectory = QDir::currentPath(); + } + + const QString selectedDirectory = QFileDialog::getExistingDirectory( + this, + tr("Select Export Directory"), + initialDirectory, + QFileDialog::ShowDirsOnly); + if (!selectedDirectory.isEmpty()) { + _ui->directoryEdit->setText(QDir::toNativeSeparators(selectedDirectory)); + } +} + +void ExportDirectoryDialog::updateOkButton() +{ + QPushButton *okButton = _ui->buttonBox->button(QDialogButtonBox::Ok); + if (okButton) { + okButton->setEnabled(!directory().isEmpty() && QDir(directory()).exists()); + } +} + +QString ExportDirectoryDialog::directory() const +{ + return _ui->directoryEdit->text(); +} diff --git a/Code/Tools/W3DViewQt/ExportDirectoryDialog.h b/Code/Tools/W3DViewQt/ExportDirectoryDialog.h new file mode 100644 index 000000000..b5643460d --- /dev/null +++ b/Code/Tools/W3DViewQt/ExportDirectoryDialog.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +namespace Ui { +class ExportDirectoryDialog; +} + +class ExportDirectoryDialog final : public QDialog +{ + Q_OBJECT + +public: + explicit ExportDirectoryDialog(const QString &fixedFilename, + QWidget *parent = nullptr); + ExportDirectoryDialog(const QString &fixedFilename, + const QString &initialDirectory, + QWidget *parent = nullptr); + ~ExportDirectoryDialog() override; + + QString selectedPath() const; + +public slots: + void accept() override; + +private slots: + void browse(); + void updateOkButton(); + +private: + QString directory() const; + + Ui::ExportDirectoryDialog *_ui = nullptr; + QString _fixedFilename; +}; diff --git a/Code/Tools/W3DViewQt/ExportDirectoryDialog.ui b/Code/Tools/W3DViewQt/ExportDirectoryDialog.ui new file mode 100644 index 000000000..f99294fde --- /dev/null +++ b/Code/Tools/W3DViewQt/ExportDirectoryDialog.ui @@ -0,0 +1,94 @@ + + + ExportDirectoryDialog + + + + 520 + 0 + + + + Export W3D + + + + + + Choose the directory for the exported definition. The filename is fixed to match the selected W3D object. + + + true + + + + + + + + + &Filename: + + + filenameEdit + + + + + + + true + + + + + + + &Directory: + + + directoryEdit + + + + + + + + + true + + + + + + + &Browse... + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + filenameEdit + directoryEdit + browseButton + buttonBox + + + + diff --git a/Code/Tools/W3DViewQt/GammaDialog.cpp b/Code/Tools/W3DViewQt/GammaDialog.cpp new file mode 100644 index 000000000..01b7f5816 --- /dev/null +++ b/Code/Tools/W3DViewQt/GammaDialog.cpp @@ -0,0 +1,54 @@ +#include "GammaDialog.h" + +#include "ui_GammaDialog.h" + +#include "dx8wrapper.h" + +#include +#include +#include + +GammaDialog::GammaDialog(QWidget *parent) + : QDialog(parent) + , _ui(new Ui::GammaDialog) +{ + _ui->setupUi(this); + + connect(_ui->gammaSlider, &QSlider::valueChanged, this, &GammaDialog::onGammaChanged); + + QSettings settings; + int gamma = settings.value("Config/Gamma", 10).toInt(); + if (gamma < 10) { + gamma = 10; + } + if (gamma > 30) { + gamma = 30; + } + _ui->gammaSlider->setValue(gamma); + _currentGamma = gamma; + onGammaChanged(gamma); + + connect(_ui->buttonBox, &QDialogButtonBox::accepted, this, [this]() { + QSettings settings; + settings.setValue("Config/Gamma", _currentGamma); + accept(); + }); + connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); +} + +GammaDialog::~GammaDialog() +{ + delete _ui; +} + +void GammaDialog::onGammaChanged(int value) +{ + _currentGamma = value; + _ui->gammaValueLabel->setText(QString("Gamma: %1").arg(value / 10.0f, 0, 'f', 2)); + applyGamma(value); +} + +void GammaDialog::applyGamma(int value) +{ + DX8Wrapper::Set_Gamma(value / 10.0f, 0.0f, 1.0f); +} diff --git a/Code/Tools/W3DViewQt/GammaDialog.h b/Code/Tools/W3DViewQt/GammaDialog.h new file mode 100644 index 000000000..d130a0d65 --- /dev/null +++ b/Code/Tools/W3DViewQt/GammaDialog.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +namespace Ui { +class GammaDialog; +} + +class GammaDialog final : public QDialog +{ + Q_OBJECT + +public: + explicit GammaDialog(QWidget *parent = nullptr); + ~GammaDialog() override; + +private slots: + void onGammaChanged(int value); + +private: + void applyGamma(int value); + + Ui::GammaDialog *_ui = nullptr; + int _currentGamma = 10; +}; diff --git a/Code/Tools/W3DViewQt/GammaDialog.ui b/Code/Tools/W3DViewQt/GammaDialog.ui new file mode 100644 index 000000000..48c1fe7dd --- /dev/null +++ b/Code/Tools/W3DViewQt/GammaDialog.ui @@ -0,0 +1,55 @@ + + + GammaDialog + + + Gamma + + + + + + Calibration instructions +A. Set Gamma to 1.0 and Monitor Contrast and Brightness to maximum +B. Adjust Monitor Brightness down so Bar 3 is barely visible +C. Adjust Monitor Contrast as preferred but Bars 1,2,3,4 must be distinguishable from each other +D. Set the Gamma using the Slider below so the gray box on the left matches its checkered surroundings +E. Press OK to save settings + + + true + + + + + + + + + + + + + + 10 + + + 30 + + + Qt::Horizontal + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + diff --git a/Code/Tools/W3DViewQt/HierarchyPropertiesDialog.cpp b/Code/Tools/W3DViewQt/HierarchyPropertiesDialog.cpp new file mode 100644 index 000000000..2c786d8c3 --- /dev/null +++ b/Code/Tools/W3DViewQt/HierarchyPropertiesDialog.cpp @@ -0,0 +1,94 @@ +#include "HierarchyPropertiesDialog.h" + +#include "MeshPropertiesDialog.h" +#include "ui_HierarchyPropertiesDialog.h" + +#include "assetmgr.h" +#include "rendobj.h" + +#include +#include + +HierarchyPropertiesDialog::HierarchyPropertiesDialog(const QString &hierarchyName, QWidget *parent) + : QDialog(parent) + , _ui(new Ui::HierarchyPropertiesDialog) + , _hierarchyName(hierarchyName) +{ + _ui->setupUi(this); + connect(_ui->subObjectList, + &QTreeWidget::itemDoubleClicked, + this, + &HierarchyPropertiesDialog::showSubObjectProperties); + connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + + if (_hierarchyName.isEmpty()) { + setErrorState("No hierarchy selected."); + return; + } + + _ui->descriptionLabel->setText(QString("Hierarchy: %1").arg(_hierarchyName)); + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + setErrorState("WW3D asset manager is not available."); + return; + } + + const QByteArray name_bytes = _hierarchyName.toLatin1(); + RenderObjClass *hierarchy = asset_manager->Create_Render_Obj(name_bytes.constData()); + if (!hierarchy) { + setErrorState("Failed to load hierarchy."); + return; + } + + _ui->polygonCountValue->setText(QString::number(hierarchy->Get_Num_Polys())); + + const int sub_count = hierarchy->Get_Num_Sub_Objects(); + _ui->subObjectCountValue->setText(QString::number(sub_count)); + + for (int index = 0; index < sub_count; ++index) { + RenderObjClass *sub_obj = hierarchy->Get_Sub_Object(index); + if (!sub_obj) { + continue; + } + + const char *sub_name = sub_obj->Get_Name(); + if (sub_name && sub_name[0]) { + auto *item = new QTreeWidgetItem(_ui->subObjectList); + item->setText(0, QString::fromLatin1(sub_name)); + } + + sub_obj->Release_Ref(); + } + + _ui->subObjectList->resizeColumnToContents(0); + hierarchy->Release_Ref(); +} + +HierarchyPropertiesDialog::~HierarchyPropertiesDialog() +{ + delete _ui; +} + +void HierarchyPropertiesDialog::showSubObjectProperties(QTreeWidgetItem *item, int column) +{ + if (!item || column != 0) { + return; + } + + const QString name = item->text(0); + if (name.isEmpty()) { + return; + } + + MeshPropertiesDialog dialog(name, this); + dialog.exec(); +} + +void HierarchyPropertiesDialog::setErrorState(const QString &message) +{ + _ui->descriptionLabel->setText(message); + _ui->polygonCountValue->setText("n/a"); + _ui->subObjectCountValue->setText("n/a"); + _ui->subObjectList->setEnabled(false); +} diff --git a/Code/Tools/W3DViewQt/HierarchyPropertiesDialog.h b/Code/Tools/W3DViewQt/HierarchyPropertiesDialog.h new file mode 100644 index 000000000..20f779acc --- /dev/null +++ b/Code/Tools/W3DViewQt/HierarchyPropertiesDialog.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +class QTreeWidgetItem; + +namespace Ui { +class HierarchyPropertiesDialog; +} + +class HierarchyPropertiesDialog final : public QDialog +{ + Q_OBJECT + +public: + explicit HierarchyPropertiesDialog(const QString &hierarchyName, QWidget *parent = nullptr); + ~HierarchyPropertiesDialog() override; + +private slots: + void showSubObjectProperties(QTreeWidgetItem *item, int column); + +private: + void setErrorState(const QString &message); + + Ui::HierarchyPropertiesDialog *_ui = nullptr; + QString _hierarchyName; +}; diff --git a/Code/Tools/W3DViewQt/HierarchyPropertiesDialog.ui b/Code/Tools/W3DViewQt/HierarchyPropertiesDialog.ui new file mode 100644 index 000000000..fb3f1312a --- /dev/null +++ b/Code/Tools/W3DViewQt/HierarchyPropertiesDialog.ui @@ -0,0 +1,89 @@ + + + HierarchyPropertiesDialog + + + Hierarchy Properties + + + + + + + + + Qt::TextSelectableByMouse + + + true + + + + + + + + + Total Polygons: + + + + + + + n/a + + + Qt::TextSelectableByMouse + + + + + + + Subobjects: + + + + + + + n/a + + + Qt::TextSelectableByMouse + + + + + + + + + QAbstractItemView::SingleSelection + + + false + + + false + + + + Name + + + + + + + + QDialogButtonBox::Close + + + + + + + + diff --git a/Code/Tools/W3DViewQt/KeyframeTableUtils.cpp b/Code/Tools/W3DViewQt/KeyframeTableUtils.cpp new file mode 100644 index 000000000..dba3194d1 --- /dev/null +++ b/Code/Tools/W3DViewQt/KeyframeTableUtils.cpp @@ -0,0 +1,142 @@ +#include "KeyframeTableUtils.h" + +#include +#include +#include + +namespace { +QDoubleSpinBox *MakeSpin(double min, double max, int decimals, double value, QWidget *parent) +{ + auto *spin = new QDoubleSpinBox(parent); + spin->setRange(min, max); + spin->setDecimals(decimals); + spin->setValue(value); + return spin; +} + +void ClearTable(QTableWidget *table) +{ + if (!table) { + return; + } + table->setRowCount(0); +} +} + +QTableWidget *CreateKeyframeTable(const QStringList &headers, + const QVector &specs, + QWidget *parent) +{ + auto *table = new QTableWidget(parent); + table->setColumnCount(headers.size()); + table->setHorizontalHeaderLabels(headers); + table->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); + table->setSelectionBehavior(QAbstractItemView::SelectRows); + table->setSelectionMode(QAbstractItemView::ExtendedSelection); + table->setEditTriggers(QAbstractItemView::NoEditTriggers); + table->setSortingEnabled(false); + table->setShowGrid(true); + table->setRowCount(0); + + return table; +} + +void SetKeyframeRows(QTableWidget *table, + const QVector> &rows, + const QVector &specs) +{ + if (!table) { + return; + } + + ClearTable(table); + for (const QVector &values : rows) { + AddKeyframeRow(table, values, specs); + } +} + +QVector> GetKeyframeRows(const QTableWidget *table) +{ + QVector> rows; + if (!table) { + return rows; + } + + const int row_count = table->rowCount(); + const int col_count = table->columnCount(); + rows.reserve(row_count); + for (int row = 0; row < row_count; ++row) { + QVector values; + values.reserve(col_count); + for (int col = 0; col < col_count; ++col) { + const QWidget *widget = table->cellWidget(row, col); + const auto *spin = qobject_cast(widget); + values.push_back(spin ? spin->value() : 0.0); + } + rows.push_back(values); + } + + return rows; +} + +void AddKeyframeRow(QTableWidget *table, + const QVector &values, + const QVector &specs) +{ + if (!table) { + return; + } + + const int row = table->rowCount(); + table->insertRow(row); + + const int col_count = table->columnCount(); + for (int col = 0; col < col_count; ++col) { + const KeyframeColumnSpec spec = col < specs.size() ? specs[col] : KeyframeColumnSpec{}; + const double value = col < values.size() ? values[col] : 0.0; + auto *spin = MakeSpin(spec.min, spec.max, spec.decimals, value, table); + table->setCellWidget(row, col, spin); + } +} + +void RemoveSelectedKeyframeRows(QTableWidget *table) +{ + if (!table) { + return; + } + + const QModelIndexList selected = table->selectionModel()->selectedRows(); + QVector rows; + rows.reserve(selected.size()); + for (const QModelIndex &index : selected) { + rows.push_back(index.row()); + } + if (rows.isEmpty()) { + const int current = table->currentRow(); + if (current >= 0) { + rows.push_back(current); + } + } + + std::sort(rows.begin(), rows.end(), [](int a, int b) { return a > b; }); + for (int row : rows) { + table->removeRow(row); + } +} + +void SortKeyframeRows(QTableWidget *table, + const QVector &specs) +{ + if (!table || table->columnCount() == 0) { + return; + } + + QVector> rows = GetKeyframeRows(table); + std::sort(rows.begin(), rows.end(), [](const QVector &a, const QVector &b) { + const double time_a = a.isEmpty() ? 0.0 : a[0]; + const double time_b = b.isEmpty() ? 0.0 : b[0]; + return time_a < time_b; + }); + + SetKeyframeRows(table, rows, specs); +} diff --git a/Code/Tools/W3DViewQt/KeyframeTableUtils.h b/Code/Tools/W3DViewQt/KeyframeTableUtils.h new file mode 100644 index 000000000..03d1ac0bd --- /dev/null +++ b/Code/Tools/W3DViewQt/KeyframeTableUtils.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include +#include + +struct KeyframeColumnSpec { + double min = 0.0; + double max = 1.0; + int decimals = 2; +}; + +QTableWidget *CreateKeyframeTable(const QStringList &headers, + const QVector &specs, + QWidget *parent); +void SetKeyframeRows(QTableWidget *table, + const QVector> &rows, + const QVector &specs); +QVector> GetKeyframeRows(const QTableWidget *table); +void AddKeyframeRow(QTableWidget *table, + const QVector &values, + const QVector &specs); +void RemoveSelectedKeyframeRows(QTableWidget *table); +void SortKeyframeRows(QTableWidget *table, + const QVector &specs); diff --git a/Code/Tools/W3DViewQt/MainWindow.cpp b/Code/Tools/W3DViewQt/MainWindow.cpp new file mode 100644 index 000000000..8592f4215 --- /dev/null +++ b/Code/Tools/W3DViewQt/MainWindow.cpp @@ -0,0 +1,5691 @@ +#include "MainWindow.h" + +#include "RenderObjUtils.h" +#include "W3DExportUtils.h" +#include "W3DViewport.h" +#include "ui_MainWindow.h" + +#include "agg_def.h" +#include "assetmgr.h" +#include "AudibleSound.h" +#include "bmp2d.h" +#include "chunkio.h" +#include "dx8wrapper.h" +#include "ffactory.h" +#include "hanim.h" +#include "hmorphanim.h" +#include "hlod.h" +#include "htree.h" +#include "matrix3d.h" +#include "part_emt.h" +#include "part_ldr.h" +#include "quat.h" +#include "rawfile.h" +#include "refcount.h" +#include "rendobj.h" +#include "ringobj.h" +#include "shader.h" +#include "soundrobj.h" +#include "textfile.h" +#include "texture.h" +#include "sphereobj.h" +#include "vector3.h" +#include "v3_rnd.h" +#include "ww3d.h" +#include "WWAudio.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "CameraDistanceDialog.h" +#include "CameraSettingsDialog.h" +#include "AddToLineupDialog.h" +#include "AdvancedAnimationDialog.h" +#include "AggregateNameDialog.h" +#include "AnimationPropertiesDialog.h" +#include "AnimationSettingsDialog.h" +#include "AnimatedSoundOptionsDialog.h" +#include "BackgroundBitmapDialog.h" +#include "BackgroundObjectDialog.h" +#include "BoneManagementDialog.h" +#include "EmitterEditDialog.h" +#include "ExportDirectoryDialog.h" +#include "ColorLightDialog.h" +#include "GammaDialog.h" +#include "HierarchyPropertiesDialog.h" +#include "MeshPropertiesDialog.h" +#include "ResolutionDialog.h" +#include "RingEditDialog.h" +#include "ScaleDialog.h" +#include "SaveSettingsDialog.h" +#include "SceneLightDialog.h" +#include "SphereEditDialog.h" +#include "SoundEditDialog.h" +#include "TexturePathDialog.h" +#include "RecentFiles.h" +#include "ShortcutHelpers.h" + +namespace { +constexpr int kRoleType = Qt::UserRole + 1; +constexpr int kRoleName = Qt::UserRole + 2; +constexpr int kRoleHierarchyName = Qt::UserRole + 3; +constexpr int kRolePointer = Qt::UserRole + 4; +constexpr int kRoleClassId = Qt::UserRole + 5; +constexpr double kPi = 3.14159265358979323846; +constexpr double kDegToRad = kPi / 180.0; +constexpr double kRadToDeg = 180.0 / kPi; +constexpr int kMaxRecentFiles = 9; + +QString NormalizeOptionalPath(const QString &path) +{ + const QString trimmed = path.trimmed(); + return trimmed.isEmpty() ? QString() : QDir::cleanPath(trimmed); +} + +QString SelectExportPath(QWidget *parent, + const QString &title, + const QString &fixedFilename, + const QString &preferredDirectory) +{ + QString initialDirectory = preferredDirectory; + if (initialDirectory.isEmpty() || !QDir(initialDirectory).exists()) { + initialDirectory = QDir::currentPath(); + } + + ExportDirectoryDialog dialog(fixedFilename, initialDirectory, parent); + dialog.setWindowTitle(title); + if (dialog.exec() != QDialog::Accepted) { + return {}; + } + return dialog.selectedPath(); +} + +enum class AssetNodeType { + None = 0, + Group = 1, + RenderObject = 2, + Animation = 3, + Material = 4, +}; + +struct RenderObjInfo { + bool isAggregate = false; + bool isRealLod = false; + QString hierarchyName; +}; + +enum class LodNamingType { + Commando = 0, + G = 1, +}; + +RenderObjInfo InspectRenderObj(const char *name) +{ + RenderObjInfo info; + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + return info; + } + + RenderObjClass *render_obj = asset_manager->Create_Render_Obj(name); + if (!render_obj) { + return info; + } + + PrototypeClass *prototype = asset_manager->Find_Prototype(name); + info.isAggregate = dynamic_cast(prototype) != nullptr || + render_obj->Get_Base_Model_Name() != nullptr; + if (render_obj->Class_ID() == RenderObjClass::CLASSID_HLOD) { + auto *hlod = static_cast(render_obj); + info.isRealLod = hlod->Get_LOD_Count() > 1; + } + + const HTreeClass *tree = render_obj->Get_HTree(); + if (tree && tree->Get_Name()) { + info.hierarchyName = QString::fromLatin1(tree->Get_Name()); + } + + render_obj->Release_Ref(); + return info; +} + +struct RenderObjectReleaser +{ + void operator()(RenderObjClass *render_object) const + { + if (render_object) { + render_object->Release_Ref(); + } + } +}; + +bool ConvertDistLodPrototype(WW3DAssetManager *asset_manager, const QString &name) +{ + if (!asset_manager || name.isEmpty()) { + return false; + } + + const QByteArray name_bytes = name.toLatin1(); + PrototypeClass *source_prototype = asset_manager->Find_Prototype(name_bytes.constData()); + if (!source_prototype) { + return false; + } + if (source_prototype->Get_Class_ID() == RenderObjClass::CLASSID_HLOD) { + return true; + } + if (source_prototype->Get_Class_ID() != RenderObjClass::CLASSID_DISTLOD) { + return false; + } + + std::unique_ptr render_object( + asset_manager->Create_Render_Obj(name_bytes.constData())); + if (!render_object) { + return false; + } + + const int object_class_id = render_object->Class_ID(); + if (object_class_id != RenderObjClass::CLASSID_HLOD) { + return false; + } + + std::unique_ptr definition( + new HLodDefClass(*static_cast(render_object.get()))); + + // The replacement prototype owns only copied definition data. Drop the + // temporary instance before deleting the prototype that created it. + render_object.reset(); + + std::unique_ptr replacement( + new HLodPrototypeClass(definition.release())); + asset_manager->Remove_Prototype(name_bytes.constData()); + asset_manager->Add_Prototype(replacement.release()); + return true; +} + +bool IsLodNameValid(const QString &name, LodNamingType &type) +{ + if (name.size() < 2) { + return false; + } + + const QChar last = name.at(name.size() - 1); + const QChar second_last = name.at(name.size() - 2); + if ((second_last == 'L' || second_last == 'l') && last.isDigit()) { + type = LodNamingType::Commando; + return true; + } + + if (last.isLetter()) { + type = LodNamingType::G; + return true; + } + + return false; +} + +bool IsModelPartOfLod(const QString &name, const QString &base, LodNamingType type) +{ + if (!name.startsWith(base)) { + return false; + } + + const QString extension = name.mid(base.size()); + if (type == LodNamingType::Commando) { + return extension.size() == 2 && + (extension.at(0) == 'L' || extension.at(0) == 'l') && + extension.at(1).isDigit(); + } + + return extension.size() == 1 && extension.at(0).isLetter(); +} + +HLodPrototypeClass *GenerateLodPrototype(const QString &base_name, LodNamingType type) +{ + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + return nullptr; + } + + RenderObjIterator *iterator = asset_manager->Create_Render_Obj_Iterator(); + if (!iterator) { + return nullptr; + } + + int lod_count = 0; + int starting_index = std::numeric_limits::max(); + QChar starting_char = 'Z'; + + for (iterator->First(); !iterator->Is_Done(); iterator->Next()) { + const char *item_name = iterator->Current_Item_Name(); + if (!item_name || !item_name[0]) { + continue; + } + + if (!asset_manager->Render_Obj_Exists(item_name)) { + continue; + } + + if (iterator->Current_Item_Class_ID() != RenderObjClass::CLASSID_HLOD) { + continue; + } + + const QString qname = QString::fromLatin1(item_name); + if (!IsModelPartOfLod(qname, base_name, type)) { + continue; + } + + ++lod_count; + const QChar last = qname.at(qname.size() - 1); + if (type == LodNamingType::Commando) { + starting_index = std::min(starting_index, last.digitValue()); + } else { + const QChar upper = last.toUpper(); + if (upper < starting_char) { + starting_char = upper; + } + } + } + + asset_manager->Release_Render_Obj_Iterator(iterator); + + if (lod_count <= 0) { + return nullptr; + } + + if (type == LodNamingType::Commando && starting_index == std::numeric_limits::max()) { + return nullptr; + } + + QVector lod_array(lod_count, nullptr); + for (int lod_index = 0; lod_index < lod_count; ++lod_index) { + QString lod_name; + if (type == LodNamingType::Commando) { + lod_name = QString("%1L%2").arg(base_name).arg(starting_index + lod_index); + } else { + lod_name = base_name + QChar(starting_char.unicode() + lod_index); + } + + const QByteArray name_bytes = lod_name.toLatin1(); + RenderObjClass *lod_obj = asset_manager->Create_Render_Obj(name_bytes.constData()); + if (!lod_obj) { + for (auto *item : lod_array) { + if (item) { + item->Release_Ref(); + } + } + return nullptr; + } + + lod_array[lod_count - (lod_index + 1)] = lod_obj; + } + + const QByteArray base_bytes = base_name.toLatin1(); + auto *new_lod = new HLodClass(base_bytes.constData(), lod_array.data(), lod_count); + auto *definition = new HLodDefClass(*new_lod); + auto *prototype = new HLodPrototypeClass(definition); + + new_lod->Release_Ref(); + for (auto *item : lod_array) { + if (item) { + item->Release_Ref(); + } + } + + return prototype; +} + +void CollectHierarchyItems(QStandardItem *parent, + const QString &hierarchyName, + QVector &items) +{ + if (!parent || hierarchyName.isEmpty()) { + return; + } + + const int count = parent->rowCount(); + for (int index = 0; index < count; ++index) { + auto *child = parent->child(index); + if (!child) { + continue; + } + + const QString itemHierarchy = child->data(kRoleHierarchyName).toString(); + if (itemHierarchy == hierarchyName) { + items.push_back(child); + } + } +} + +void CollectAllChildren(QStandardItem *parent, QVector &items) +{ + if (!parent) { + return; + } + + const int count = parent->rowCount(); + for (int index = 0; index < count; ++index) { + auto *child = parent->child(index); + if (child) { + items.push_back(child); + } + } +} + +void AdjustLightIntensity(Vector3 &color, float inc) +{ + color.X = std::clamp(color.X + inc, 0.0f, 1.0f); + color.Y = std::clamp(color.Y + inc, 0.0f, 1.0f); + color.Z = std::clamp(color.Z + inc, 0.0f, 1.0f); +} + +void SortAnimationChildren(QStandardItem *parent) +{ + if (!parent) { + return; + } + + const int count = parent->rowCount(); + for (int index = 0; index < count; ++index) { + auto *child = parent->child(index); + if (child) { + child->sortChildren(0, Qt::AscendingOrder); + } + } +} + +void SetHighestLod(RenderObjClass *render_obj) +{ + if (!render_obj) { + return; + } + + const int count = render_obj->Get_Num_Sub_Objects(); + for (int index = 0; index < count; ++index) { + RenderObjClass *sub_obj = render_obj->Get_Sub_Object(index); + if (sub_obj) { + SetHighestLod(sub_obj); + sub_obj->Release_Ref(); + } + } + + if (render_obj->Class_ID() == RenderObjClass::CLASSID_HLOD) { + auto *hlod = static_cast(render_obj); + const int max_level = hlod->Get_LOD_Count() - 1; + if (max_level >= 0) { + hlod->Set_LOD_Level(max_level); + } + } +} + +bool GetSelectedRenderObject(QTreeView *tree, QString &name, int &class_id) +{ + if (!tree) { + return false; + } + + const QModelIndex current = tree->currentIndex(); + if (!current.isValid()) { + return false; + } + + if (current.data(kRoleType).toInt() != static_cast(AssetNodeType::RenderObject)) { + return false; + } + + name = current.data(kRoleName).toString(); + class_id = current.data(kRoleClassId).toInt(); + return !name.isEmpty(); +} + +bool GetSelectedRenderObjectName(QTreeView *tree, QString &name) +{ + if (!tree) { + return false; + } + + QModelIndex current = tree->currentIndex(); + if (!current.isValid()) { + return false; + } + + const int type_value = current.data(kRoleType).toInt(); + if (type_value == static_cast(AssetNodeType::RenderObject)) { + name = current.data(kRoleName).toString(); + return !name.isEmpty(); + } + + if (type_value == static_cast(AssetNodeType::Animation)) { + QModelIndex render_index = current.parent(); + while (render_index.isValid() && + render_index.data(kRoleType).toInt() != static_cast(AssetNodeType::RenderObject)) { + render_index = render_index.parent(); + } + if (!render_index.isValid()) { + return false; + } + name = render_index.data(kRoleName).toString(); + return !name.isEmpty(); + } + + return false; +} + +QString GetSelectedHierarchyName(QTreeView *tree) +{ + if (!tree) { + return QString(); + } + + QModelIndex current = tree->currentIndex(); + while (current.isValid()) { + const QString hierarchy = current.data(kRoleHierarchyName).toString(); + if (!hierarchy.isEmpty()) { + return hierarchy; + } + current = current.parent(); + } + + return QString(); +} + +QModelIndex FindRenderObjectIndex(QStandardItemModel *model, const QString &name, int class_id) +{ + if (!model || name.isEmpty() || model->rowCount() <= 0) { + return QModelIndex(); + } + + const QModelIndex start = model->index(0, 0); + const QModelIndexList matches = model->match(start, + kRoleName, + name, + -1, + Qt::MatchExactly | Qt::MatchRecursive); + for (const QModelIndex &match : matches) { + if (!match.isValid()) { + continue; + } + if (match.data(kRoleType).toInt() != static_cast(AssetNodeType::RenderObject)) { + continue; + } + if (class_id >= 0 && match.data(kRoleClassId).toInt() != class_id) { + continue; + } + return match; + } + + return QModelIndex(); +} + +void ExpandParentChain(QTreeView *tree, QModelIndex index) +{ + if (!tree) { + return; + } + + while (index.isValid()) { + tree->expand(index); + index = index.parent(); + } +} + +QString ResolveGroupLabel(QStandardItemModel *model, const QModelIndex &index, bool is_group) +{ + if (!model || !index.isValid()) { + return QString(); + } + + auto *item = model->itemFromIndex(index); + if (!item) { + return QString(); + } + + if (is_group) { + return item->text(); + } + + if (auto *parent = item->parent()) { + return parent->text(); + } + + return QString(); +} + +QString FindHierarchyAssetPath(const QString &directory, const QString &hierarchy) +{ + if (directory.isEmpty() || hierarchy.isEmpty()) { + return QString(); + } + + QDir dir(directory); + QString base = hierarchy; + if (base.endsWith(".w3d", Qt::CaseInsensitive)) { + base.chop(4); + } + + const QString direct_path = dir.filePath(base + ".w3d"); + if (QFileInfo::exists(direct_path)) { + return direct_path; + } + + const auto entries = dir.entryInfoList(QStringList() << "*.w3d" << "*.W3D", QDir::Files); + for (const auto &info : entries) { + if (info.completeBaseName().compare(base, Qt::CaseInsensitive) == 0) { + return info.absoluteFilePath(); + } + } + + return QString(); +} + +void LoadMissingHierarchyAssets(WW3DAssetManager *asset_manager, const QString &directory) +{ + if (!asset_manager || directory.isEmpty()) { + return; + } + + QSet loaded_hierarchies; + RenderObjIterator *render_iter = asset_manager->Create_Render_Obj_Iterator(); + if (render_iter) { + for (render_iter->First(); !render_iter->Is_Done(); render_iter->Next()) { + const char *name = render_iter->Current_Item_Name(); + if (!name || !name[0]) { + continue; + } + + const RenderObjInfo info = InspectRenderObj(name); + if (!info.hierarchyName.isEmpty()) { + loaded_hierarchies.insert(info.hierarchyName.toUpper()); + } + } + asset_manager->Release_Render_Obj_Iterator(render_iter); + } + + QSet anim_hierarchies; + AssetIterator *anim_iter = asset_manager->Create_HAnim_Iterator(); + if (anim_iter) { + for (anim_iter->First(); !anim_iter->Is_Done(); anim_iter->Next()) { + const char *anim_name = anim_iter->Current_Item_Name(); + if (!anim_name || !anim_name[0]) { + continue; + } + + HAnimClass *anim = asset_manager->Get_HAnim(anim_name); + if (!anim) { + continue; + } + + const char *hier_name = anim->Get_HName(); + if (hier_name && hier_name[0]) { + anim_hierarchies.insert(QString::fromLatin1(hier_name).toUpper()); + } + anim->Release_Ref(); + } + delete anim_iter; + } + + for (const auto &hierarchy : anim_hierarchies) { + if (loaded_hierarchies.contains(hierarchy)) { + continue; + } + + const QString path = FindHierarchyAssetPath(directory, hierarchy); + if (path.isEmpty()) { + continue; + } + + const QByteArray path_bytes = QDir::toNativeSeparators(path).toLocal8Bit(); + if (asset_manager->Load_3D_Assets(path_bytes.constData())) { + loaded_hierarchies.insert(hierarchy); + } + } +} + +bool ImportFacialAnimation(const QString &hierarchy, const QString &path) +{ + if (hierarchy.isEmpty() || path.isEmpty()) { + return false; + } + + const QByteArray file_native = QDir::toNativeSeparators(path).toLocal8Bit(); + TextFileClass anim_desc_file(file_native.constData()); + if (!anim_desc_file.Open()) { + return false; + } + + HMorphAnimClass *new_anim = new HMorphAnimClass; + const QByteArray hierarchy_bytes = hierarchy.toLatin1(); + if (!new_anim->Import(hierarchy_bytes.constData(), anim_desc_file)) { + anim_desc_file.Close(); + new_anim->Release_Ref(); + return false; + } + + const QString anim_name = QFileInfo(path).completeBaseName().toUpper(); + const QString new_name = QString("%1.%2").arg(hierarchy, anim_name); + const QByteArray new_name_bytes = new_name.toLatin1(); + new_anim->Set_Name(new_name_bytes.constData()); + + if (auto *asset_manager = WW3DAssetManager::Get_Instance()) { + asset_manager->Add_Anim(new_anim); + } + + const QString output_path = QDir(QFileInfo(path).absolutePath()).filePath(anim_name + ".w3d"); + const QByteArray output_native = QDir::toNativeSeparators(output_path).toLocal8Bit(); + RawFileClass animation_file(output_native.constData()); + if (animation_file.Create() == (int)true && + animation_file.Open(FileClass::WRITE) == (int)true) { + ChunkSaveClass csave(&animation_file); + new_anim->Save_W3D(csave); + animation_file.Close(); + } + + anim_desc_file.Close(); + new_anim->Release_Ref(); + return true; +} + +ParticleEmitterDefClass CreateDefaultEmitterDefinition() +{ + ParticlePropertyStruct color; + color.Start = Vector3(1, 1, 1); + color.Rand.Set(0, 0, 0); + color.NumKeyFrames = 0; + color.KeyTimes = nullptr; + color.Values = nullptr; + + ParticlePropertyStruct opacity; + opacity.Start = 1.0f; + opacity.Rand = 0.0f; + opacity.NumKeyFrames = 0; + opacity.KeyTimes = nullptr; + opacity.Values = nullptr; + + ParticlePropertyStruct size; + size.Start = 0.1f; + size.Rand = 0.0f; + size.NumKeyFrames = 0; + size.KeyTimes = nullptr; + size.Values = nullptr; + + ParticlePropertyStruct rotation; + rotation.Start = 0.0f; + rotation.Rand = 0.0f; + rotation.NumKeyFrames = 0; + rotation.KeyTimes = nullptr; + rotation.Values = nullptr; + + ParticlePropertyStruct frames; + frames.Start = 0.0f; + frames.Rand = 0.0f; + frames.NumKeyFrames = 0; + frames.KeyTimes = nullptr; + frames.Values = nullptr; + + ParticlePropertyStruct blur_times; + blur_times.Start = 0.0f; + blur_times.Rand = 0.0f; + blur_times.NumKeyFrames = 0; + blur_times.KeyTimes = nullptr; + blur_times.Values = nullptr; + + auto *emitter = new ParticleEmitterClass( + 10.0f, + 1, + new Vector3SolidBoxRandomizer(Vector3(0.1f, 0.1f, 0.1f)), + Vector3(0, 0, 1), + new Vector3SolidBoxRandomizer(Vector3(0, 0, 0.1f)), + 0.0f, + 0.0f, + color, + opacity, + size, + rotation, + 0.0f, + frames, + blur_times, + Vector3(0, 0, 0), + 1.0f, + nullptr, + ShaderClass::_PresetAdditiveSpriteShader, + 0); + + ParticleEmitterDefClass *definition = emitter->Build_Definition(); + ParticleEmitterDefClass copy; + if (definition) { + copy = *definition; + delete definition; + } + emitter->Release_Ref(); + return copy; +} + +bool UpdateEmitterPrototype(const ParticleEmitterDefClass &definition, + const QString &old_name, + QString *errorMessage = nullptr) +{ + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + if (errorMessage) { + *errorMessage = "WW3D asset manager is not available."; + } + return false; + } + + const char *definition_name = definition.Get_Name(); + const QString new_name = definition_name ? QString::fromLatin1(definition_name) : QString(); + if (new_name.isEmpty()) { + if (errorMessage) { + *errorMessage = "Emitter name is required."; + } + return false; + } + + const QByteArray new_bytes = new_name.toLatin1(); + const bool replaces_registered_name = !old_name.isEmpty() && + old_name.compare(new_name, Qt::CaseInsensitive) == 0; + if (asset_manager->Find_Prototype(new_bytes.constData()) && !replaces_registered_name) { + if (errorMessage) { + *errorMessage = QString("An asset named '%1' already exists.").arg(new_name); + } + return false; + } + + auto definition_copy = std::make_unique(definition); + auto prototype = std::make_unique(definition_copy.release()); + + if (!old_name.isEmpty()) { + const QByteArray old_bytes = old_name.toLatin1(); + asset_manager->Remove_Prototype(old_bytes.constData()); + } + + asset_manager->Add_Prototype(prototype.release()); + return true; +} + +} // namespace + +W3DViewMainWindow::W3DViewMainWindow(QWidget *parent) + : QMainWindow(parent) + , _ui(new Ui::W3DViewMainWindow) +{ + // Asset parsing must not depend on whether the native Direct3D viewport + // has already initialized. This also keeps startup-file loading reliable + // when the render device is temporarily unavailable. + if (auto *asset_manager = WW3DAssetManager::Get_Instance()) { + asset_manager->Register_Prototype_Loader(&_ParticleEmitterLoader); + asset_manager->Register_Prototype_Loader(&_RingLoader); + asset_manager->Register_Prototype_Loader(&_SphereLoader); + asset_manager->Register_Prototype_Loader(&_SoundRenderObjLoader); + } + + _ui->setupUi(this); + setAcceptDrops(true); + + _fileMenu = _ui->fileMenu; + _emittersEditMenu = _ui->emittersEditMenu; + _objectMenuAction = _ui->objectMenu->menuAction(); + _mainToolbar = _ui->MainToolbar; + _objectToolbar = _ui->ObjectToolbar; + _animationToolbar = _ui->AnimationToolbar; + _toolbarMainAction = _ui->actionToolbarMain; + _toolbarObjectAction = _ui->actionToolbarObject; + _toolbarAnimationAction = _ui->actionToolbarAnimation; + _newAction = _ui->actionNew; + _openAction = _ui->actionOpen; + _recentFilesPlaceholderAction = _ui->actionRecentFilesPlaceholder; + _texturePathsAction = _ui->actionTexturePaths; + _autoExpandTreeAction = _ui->actionAutoExpandAssetTree; + _loadSettingsAction = _ui->actionLoadSettings; + _saveSettingsAction = _ui->actionSaveSettings; + _enableGammaAction = _ui->actionEnableGammaCorrection; + _mungeSortAction = _ui->actionMungeSortOnLoad; + _exportAggregateAction = _ui->actionExportAggregate; + _exportEmitterAction = _ui->actionExportEmitter; + _exportLodAction = _ui->actionExportLod; + _exportPrimitiveAction = _ui->actionExportPrimitive; + _exportSoundObjectAction = _ui->actionExportSoundObject; + _editSoundObjectAction = _ui->actionEditSoundObject; + _editEmitterAction = _ui->actionEditEmitter; + _scaleEmitterAction = _ui->actionScaleEmitter; + _editPrimitiveAction = _ui->actionEditPrimitive; + _listMissingTexturesAction = _ui->actionListMissingTextures; + _copyAssetsAction = _ui->actionCopyAssets; + _addToLineupAction = _ui->actionAddToLineup; + _aboutAction = _ui->actionAbout; + _wireframeAction = _ui->actionWireframe; + _sortingAction = _ui->actionSorting; + _restrictAnimsAction = _ui->actionRestrictAnims; + _statusBarAction = _ui->actionStatusBar; + _fogAction = _ui->actionFog; + _gammaAction = _ui->actionGamma; + _invertBackfaceCullingAction = _ui->actionInvertBackfaceCulling; + _backgroundObjectAction = _ui->actionBackgroundObject; + _captureScreenshotAction = _ui->actionCaptureScreenshot; + _makeMovieAction = _ui->actionMakeMovie; + _slideshowPrevAction = _ui->actionSlideshowPrev; + _slideshowNextAction = _ui->actionSlideshowNext; + _objectRotateXAction = _ui->actionObjectRotateX; + _objectRotateYAction = _ui->actionObjectRotateY; + _objectRotateZAction = _ui->actionObjectRotateZ; + _objectResetAction = _ui->actionObjectReset; + _objectAlternateAction = _ui->actionObjectAlternateMaterials; + _objectPropertiesAction = _ui->actionObjectProperties; + _cameraFrontAction = _ui->actionCameraFront; + _cameraBackAction = _ui->actionCameraBack; + _cameraLeftAction = _ui->actionCameraLeft; + _cameraRightAction = _ui->actionCameraRight; + _cameraTopAction = _ui->actionCameraTop; + _cameraBottomAction = _ui->actionCameraBottom; + _cameraRotateXAction = _ui->actionCameraRotateX; + _cameraRotateYAction = _ui->actionCameraRotateY; + _cameraRotateZAction = _ui->actionCameraRotateZ; + _cameraCopyScreenAction = _ui->actionCameraCopyScreen; + _cameraAnimateAction = _ui->actionCameraAnimate; + _cameraResetOnDisplayAction = _ui->actionCameraResetOnDisplay; + _cameraResetAction = _ui->actionCameraReset; + _cameraBonePosXAction = _ui->actionCameraBonePosX; + _cameraSettingsAction = _ui->actionCameraSettings; + _cameraDistanceAction = _ui->actionCameraDistance; + _npatchesGapAction = _ui->actionNpatchesGap; + _lightRotateYAction = _ui->actionLightRotateY; + _lightRotateZAction = _ui->actionLightRotateZ; + _exposePrelitAction = _ui->actionExposePrelit; + _prelitVertexAction = _ui->actionPrelitVertex; + _prelitMultipassAction = _ui->actionPrelitMultipass; + _prelitMultitexAction = _ui->actionPrelitMultitex; + applyMainToolbarIcons(); + + connect(_newAction, &QAction::triggered, this, &W3DViewMainWindow::newFile); + connect(_openAction, &QAction::triggered, this, &W3DViewMainWindow::openFile); + connect(_mungeSortAction, &QAction::triggered, this, &W3DViewMainWindow::toggleMungeSortOnLoad); + connect(_enableGammaAction, &QAction::triggered, this, &W3DViewMainWindow::toggleGammaCorrection); + connect(_saveSettingsAction, &QAction::triggered, this, &W3DViewMainWindow::saveSettingsFile); + connect(_loadSettingsAction, &QAction::triggered, this, &W3DViewMainWindow::loadSettingsFile); + connect(_ui->actionImportFacialAnims, &QAction::triggered, + this, &W3DViewMainWindow::importFacialAnims); + connect(_exportAggregateAction, &QAction::triggered, this, &W3DViewMainWindow::exportAggregate); + connect(_exportEmitterAction, &QAction::triggered, this, &W3DViewMainWindow::exportEmitter); + connect(_exportLodAction, &QAction::triggered, this, &W3DViewMainWindow::exportLod); + connect(_exportPrimitiveAction, &QAction::triggered, this, &W3DViewMainWindow::exportPrimitive); + connect(_exportSoundObjectAction, &QAction::triggered, this, &W3DViewMainWindow::exportSoundObject); + connect(_ui->actionFileTexturePath, &QAction::triggered, + this, &W3DViewMainWindow::openTexturePathsDialog); + connect(_ui->actionAnimatedSoundOptions, &QAction::triggered, + this, &W3DViewMainWindow::openAnimatedSoundOptions); + updateRecentFilesMenu(); + connect(_ui->actionExit, &QAction::triggered, this, &QWidget::close); + + connect(_texturePathsAction, &QAction::triggered, this, &W3DViewMainWindow::openTexturePathsDialog); + connect(_autoExpandTreeAction, &QAction::toggled, this, &W3DViewMainWindow::toggleAutoExpandAssetTree); + + connect(_toolbarMainAction, &QAction::toggled, this, &W3DViewMainWindow::toggleMainToolbar); + connect(_toolbarObjectAction, &QAction::toggled, this, &W3DViewMainWindow::toggleObjectToolbar); + connect(_toolbarAnimationAction, &QAction::toggled, this, &W3DViewMainWindow::toggleAnimationToolbar); + connect(_animationToolbar, &QToolBar::visibilityChanged, this, [this](bool visible) { + if (_toolbarAnimationAction) { + const QSignalBlocker blocker(_toolbarAnimationAction); + _toolbarAnimationAction->setChecked(visible); + } + if (!_changingAnimationToolbarForSelection) { + _showAnimationToolbar = visible; + } + }); + connect(_statusBarAction, &QAction::triggered, this, &W3DViewMainWindow::toggleStatusBar); + connect(_slideshowPrevAction, &QAction::triggered, this, &W3DViewMainWindow::selectPrevAsset); + connect(_slideshowNextAction, &QAction::triggered, this, &W3DViewMainWindow::selectNextAsset); + connect(_wireframeAction, &QAction::triggered, this, &W3DViewMainWindow::toggleWireframe); + connect(_sortingAction, &QAction::triggered, this, &W3DViewMainWindow::toggleSorting); + connect(_invertBackfaceCullingAction, &QAction::triggered, + this, &W3DViewMainWindow::toggleBackfaceCulling); + connect(_gammaAction, &QAction::triggered, this, &W3DViewMainWindow::openGammaDialog); + connect(_ui->actionChangeResolution, + &QAction::triggered, + this, + &W3DViewMainWindow::changeResolution); + _npatchesGroup = new QActionGroup(this); + _npatchesGroup->setObjectName("npatchesGroup"); + _npatchesGroup->setExclusive(true); + for (int level = 1; level <= 8; ++level) { + auto *action = _ui->npatchesMenu->addAction(QString::number(level)); + action->setObjectName(QString("actionNpatchesLevel%1").arg(level)); + action->setCheckable(true); + action->setData(level); + _npatchesGroup->addAction(action); + connect(action, &QAction::triggered, this, [this, level]() { setNpatchesLevel(level); }); + } + connect(_npatchesGapAction, &QAction::triggered, this, &W3DViewMainWindow::toggleNpatchesGap); + + connect(_objectRotateXAction, &QAction::triggered, this, &W3DViewMainWindow::toggleObjectRotateX); + _objectRotateYAction->setShortcuts( + QList{QKeySequence(Qt::Key_Up), QKeySequence(Qt::CTRL | Qt::Key_Y)}); + connect(_objectRotateYAction, &QAction::triggered, this, &W3DViewMainWindow::toggleObjectRotateY); + _objectRotateZAction->setShortcuts( + QList{QKeySequence(Qt::Key_Right), QKeySequence(Qt::CTRL | Qt::Key_Z)}); + connect(_objectRotateZAction, &QAction::triggered, this, &W3DViewMainWindow::toggleObjectRotateZ); + connect(_objectPropertiesAction, &QAction::triggered, this, &W3DViewMainWindow::showObjectProperties); + connect(_restrictAnimsAction, &QAction::triggered, this, &W3DViewMainWindow::toggleRestrictAnims); + connect(_objectResetAction, &QAction::triggered, this, &W3DViewMainWindow::resetObject); + connect(_objectAlternateAction, &QAction::triggered, this, &W3DViewMainWindow::toggleAlternateMaterials); + + _animationMenu = new QMenu("&Animation", this); + _animationMenu->setObjectName("animationMenu"); + _animationPlayAction = _ui->actionToolbarAnimationPlay; + _animationPauseAction = _ui->actionToolbarAnimationPause; + _animationStopAction = _ui->actionToolbarAnimationStop; + _animationStepBackAction = _ui->actionToolbarAnimationStepBack; + _animationStepForwardAction = _ui->actionToolbarAnimationStepForward; + _animationMenu->addAction(_animationPlayAction); + _animationMenu->addAction(_animationPauseAction); + _animationMenu->addAction(_animationStopAction); + connect(_animationPlayAction, &QAction::triggered, this, &W3DViewMainWindow::startAnimation); + connect(_animationPauseAction, &QAction::triggered, this, &W3DViewMainWindow::pauseAnimation); + connect(_animationStopAction, &QAction::triggered, this, &W3DViewMainWindow::stopAnimation); + _animationMenu->addSeparator(); + _animationMenu->addAction(_animationStepBackAction); + _animationMenu->addAction(_animationStepForwardAction); + connect(_animationStepBackAction, &QAction::triggered, this, &W3DViewMainWindow::stepAnimationBackward); + connect(_animationStepForwardAction, &QAction::triggered, this, &W3DViewMainWindow::stepAnimationForward); + _animationMenu->addSeparator(); + auto *animation_settings_action = _animationMenu->addAction("Se&ttings"); + animation_settings_action->setObjectName("actionAnimationSettings"); + connect(animation_settings_action, &QAction::triggered, this, &W3DViewMainWindow::openAnimationSettings); + _animationMenu->addSeparator(); + auto *animation_advanced_action = _animationMenu->addAction("Ad&vanced..."); + animation_advanced_action->setObjectName("actionAnimationAdvanced"); + connect(animation_advanced_action, &QAction::triggered, this, &W3DViewMainWindow::openAdvancedAnimation); + connect(_animationMenu, &QMenu::aboutToShow, this, &W3DViewMainWindow::refreshAnimationMenu); + + _hierarchyMenu = new QMenu("&Hierarchy", this); + _hierarchyMenu->setObjectName("hierarchyMenu"); + auto *hierarchy_generate_action = _hierarchyMenu->addAction("&Generate LOD..."); + hierarchy_generate_action->setObjectName("actionHierarchyGenerateLod"); + connect(hierarchy_generate_action, &QAction::triggered, this, &W3DViewMainWindow::generateLod); + auto *hierarchy_aggregate_action = _hierarchyMenu->addAction("&Make Aggregate..."); + hierarchy_aggregate_action->setObjectName("actionHierarchyMakeAggregate"); + connect(hierarchy_aggregate_action, &QAction::triggered, this, &W3DViewMainWindow::makeAggregate); + + _aggregateMenu = new QMenu("&Aggregate", this); + _aggregateMenu->setObjectName("aggregateMenu"); + auto *aggregate_rename_action = _aggregateMenu->addAction("R&ename Aggregate..."); + aggregate_rename_action->setObjectName("actionAggregateRename"); + connect(aggregate_rename_action, &QAction::triggered, this, &W3DViewMainWindow::renameAggregate); + _aggregateMenu->addSeparator(); + auto *aggregate_bone_action = _aggregateMenu->addAction("&Bone Management..."); + aggregate_bone_action->setObjectName("actionAggregateBoneManagement"); + connect(aggregate_bone_action, &QAction::triggered, this, &W3DViewMainWindow::openBoneManagement); + auto *aggregate_auto_assign_action = _aggregateMenu->addAction("&Auto Assign Bone Models"); + aggregate_auto_assign_action->setObjectName("actionAggregateAutoAssignBones"); + connect(aggregate_auto_assign_action, &QAction::triggered, this, &W3DViewMainWindow::autoAssignBoneModels); + _aggregateMenu->addSeparator(); + _aggregateBindSubobjectAction = _aggregateMenu->addAction("Bind &Subobject LOD"); + _aggregateBindSubobjectAction->setObjectName("actionAggregateBindSubobjectLod"); + _aggregateBindSubobjectAction->setCheckable(true); + connect(_aggregateBindSubobjectAction, &QAction::triggered, this, &W3DViewMainWindow::bindSubobjectLod); + auto *aggregate_generate_action = _aggregateMenu->addAction("&Generate LOD..."); + aggregate_generate_action->setObjectName("actionAggregateGenerateLod"); + connect(aggregate_generate_action, &QAction::triggered, this, &W3DViewMainWindow::generateLod); + connect(_aggregateMenu, &QMenu::aboutToShow, this, &W3DViewMainWindow::refreshAggregateMenu); + + _lodMenu = new QMenu("&LOD", this); + _lodMenu->setObjectName("lodMenu"); + _lodRecordAction = _lodMenu->addAction("&Record Screen Area"); + _lodRecordAction->setObjectName("actionLodRecordScreenArea"); + connect(_lodRecordAction, &QAction::triggered, this, &W3DViewMainWindow::recordLodScreenArea); + _lodIncludeNullAction = _lodMenu->addAction("Include &NULL Object"); + _lodIncludeNullAction->setObjectName("actionLodIncludeNull"); + _lodIncludeNullAction->setCheckable(true); + connect(_lodIncludeNullAction, &QAction::triggered, this, &W3DViewMainWindow::toggleLodIncludeNull); + _lodMenu->addSeparator(); + _lodPrevAction = _lodMenu->addAction("&Prev Level"); + _lodPrevAction->setObjectName("actionLodPrevious"); + connect(_lodPrevAction, &QAction::triggered, this, &W3DViewMainWindow::selectPrevLod); + _lodNextAction = _lodMenu->addAction("&Next Level"); + _lodNextAction->setObjectName("actionLodNext"); + connect(_lodNextAction, &QAction::triggered, this, &W3DViewMainWindow::selectNextLod); + _lodAutoSwitchAction = _lodMenu->addAction("&Auto Switching"); + _lodAutoSwitchAction->setObjectName("actionLodAutoSwitch"); + _lodAutoSwitchAction->setCheckable(true); + connect(_lodAutoSwitchAction, &QAction::triggered, this, &W3DViewMainWindow::toggleLodAutoSwitch); + _lodMenu->addSeparator(); + auto *lod_make_aggregate_action = _lodMenu->addAction("&Make Aggregate..."); + lod_make_aggregate_action->setObjectName("actionLodMakeAggregate"); + connect(lod_make_aggregate_action, &QAction::triggered, this, &W3DViewMainWindow::makeAggregate); + connect(_lodMenu, &QMenu::aboutToShow, this, &W3DViewMainWindow::refreshLodMenu); + + connect(_ui->actionCreateEmitter, &QAction::triggered, + this, &W3DViewMainWindow::createEmitter); + connect(_scaleEmitterAction, &QAction::triggered, this, &W3DViewMainWindow::scaleEmitter); + connect(_editEmitterAction, &QAction::triggered, this, &W3DViewMainWindow::editEmitter); + connect(_emittersEditMenu, &QMenu::aboutToShow, this, &W3DViewMainWindow::updateEmittersEditMenu); + + connect(_ui->actionCreateSphere, &QAction::triggered, + this, &W3DViewMainWindow::createSphere); + connect(_ui->actionCreateRing, &QAction::triggered, + this, &W3DViewMainWindow::createRing); + connect(_editPrimitiveAction, &QAction::triggered, + this, &W3DViewMainWindow::editPrimitive); + + connect(_ui->actionCreateSoundObject, &QAction::triggered, + this, &W3DViewMainWindow::createSoundObject); + connect(_editSoundObjectAction, &QAction::triggered, this, &W3DViewMainWindow::editSoundObject); + + connect(_lightRotateYAction, &QAction::triggered, this, &W3DViewMainWindow::toggleLightRotateY); + connect(_lightRotateZAction, &QAction::triggered, this, &W3DViewMainWindow::toggleLightRotateZ); + connect(_ui->actionAmbientLight, &QAction::triggered, + this, &W3DViewMainWindow::setAmbientLight); + connect(_ui->actionSceneLight, &QAction::triggered, + this, &W3DViewMainWindow::setSceneLight); + _ui->actionIncreaseAmbientLight->setShortcuts( + QList{QKeySequence(Qt::Key_Plus), QKeySequence(Qt::Key_Equal)}); + connect(_ui->actionIncreaseAmbientLight, &QAction::triggered, + this, &W3DViewMainWindow::increaseAmbientLight); + connect(_ui->actionDecreaseAmbientLight, &QAction::triggered, + this, &W3DViewMainWindow::decreaseAmbientLight); + _ui->actionIncreaseSceneLight->setShortcuts( + QList{QKeySequence(Qt::CTRL | Qt::Key_Plus), + QKeySequence(Qt::CTRL | Qt::Key_Equal)}); + connect(_ui->actionIncreaseSceneLight, &QAction::triggered, + this, &W3DViewMainWindow::increaseSceneLight); + connect(_ui->actionDecreaseSceneLight, &QAction::triggered, + this, &W3DViewMainWindow::decreaseSceneLight); + connect(_exposePrelitAction, &QAction::triggered, this, &W3DViewMainWindow::toggleExposePrelit); + connect(_ui->actionKillSceneLight, &QAction::triggered, + this, &W3DViewMainWindow::killSceneLight); + _prelitGroup = new QActionGroup(this); + _prelitGroup->setObjectName("prelitGroup"); + _prelitGroup->addAction(_prelitVertexAction); + connect(_prelitVertexAction, &QAction::triggered, this, &W3DViewMainWindow::setPrelitVertex); + _prelitGroup->addAction(_prelitMultipassAction); + connect(_prelitMultipassAction, &QAction::triggered, this, &W3DViewMainWindow::setPrelitMultipass); + _prelitGroup->addAction(_prelitMultitexAction); + connect(_prelitMultitexAction, &QAction::triggered, this, &W3DViewMainWindow::setPrelitMultitex); + + connect(_cameraFrontAction, &QAction::triggered, this, &W3DViewMainWindow::setCameraFront); + connect(_cameraBackAction, &QAction::triggered, this, &W3DViewMainWindow::setCameraBack); + connect(_cameraLeftAction, &QAction::triggered, this, &W3DViewMainWindow::setCameraLeft); + connect(_cameraRightAction, &QAction::triggered, this, &W3DViewMainWindow::setCameraRight); + connect(_cameraTopAction, &QAction::triggered, this, &W3DViewMainWindow::setCameraTop); + connect(_cameraBottomAction, &QAction::triggered, this, &W3DViewMainWindow::setCameraBottom); + connect(_cameraRotateXAction, &QAction::triggered, this, &W3DViewMainWindow::setCameraRotateX); + connect(_cameraRotateYAction, &QAction::triggered, this, &W3DViewMainWindow::setCameraRotateY); + connect(_cameraRotateZAction, &QAction::triggered, this, &W3DViewMainWindow::setCameraRotateZ); + connect(_cameraCopyScreenAction, &QAction::triggered, this, &W3DViewMainWindow::copyScreenSize); + connect(_cameraAnimateAction, &QAction::triggered, this, &W3DViewMainWindow::toggleCameraAnimate); + connect(_cameraBonePosXAction, &QAction::triggered, this, &W3DViewMainWindow::toggleCameraBonePosX); + connect(_cameraSettingsAction, &QAction::triggered, this, &W3DViewMainWindow::openCameraSettings); + connect(_cameraDistanceAction, &QAction::triggered, this, &W3DViewMainWindow::openCameraDistance); + connect(_cameraResetOnDisplayAction, &QAction::triggered, this, &W3DViewMainWindow::toggleCameraResetOnDisplay); + connect(_cameraResetAction, &QAction::triggered, this, &W3DViewMainWindow::resetCamera); + + connect(_ui->actionBackgroundColor, &QAction::triggered, + this, &W3DViewMainWindow::setBackgroundColor); + connect(_ui->actionBackgroundBitmap, &QAction::triggered, + this, &W3DViewMainWindow::setBackgroundBitmap); + connect(_backgroundObjectAction, &QAction::triggered, this, + &W3DViewMainWindow::openBackgroundObjectDialog); + connect(_fogAction, &QAction::triggered, this, &W3DViewMainWindow::toggleFog); + + connect(_makeMovieAction, &QAction::triggered, this, &W3DViewMainWindow::makeMovie); + connect(_captureScreenshotAction, &QAction::triggered, this, + &W3DViewMainWindow::captureScreenshot); + + connect(_aboutAction, &QAction::triggered, this, &W3DViewMainWindow::showAbout); + + auto *make_aggregate_shortcut = + qtcommon::CreateWindowShortcutAction(this, QList{QKeySequence(Qt::CTRL | Qt::Key_A)}); + if (make_aggregate_shortcut != nullptr) { + make_aggregate_shortcut->setObjectName("shortcutMakeAggregate"); + connect(make_aggregate_shortcut, &QAction::triggered, this, &W3DViewMainWindow::makeAggregate); + } + auto *advanced_animation_shortcut = + qtcommon::CreateWindowShortcutAction(this, QList{QKeySequence(Qt::CTRL | Qt::Key_V)}); + if (advanced_animation_shortcut != nullptr) { + advanced_animation_shortcut->setObjectName("shortcutAdvancedAnimation"); + connect(advanced_animation_shortcut, &QAction::triggered, + this, &W3DViewMainWindow::openAdvancedAnimation); + } + auto *lod_record_shortcut = + qtcommon::CreateWindowShortcutAction(this, QList{QKeySequence(Qt::Key_Space)}); + if (lod_record_shortcut != nullptr) { + lod_record_shortcut->setObjectName("shortcutLodRecordScreenArea"); + connect(lod_record_shortcut, &QAction::triggered, this, &W3DViewMainWindow::recordLodScreenArea); + } + auto *lod_prev_shortcut = + qtcommon::CreateWindowShortcutAction(this, QList{QKeySequence(Qt::Key_BracketLeft)}); + if (lod_prev_shortcut != nullptr) { + lod_prev_shortcut->setObjectName("shortcutLodPrevious"); + connect(lod_prev_shortcut, &QAction::triggered, this, &W3DViewMainWindow::selectPrevLod); + } + auto *lod_next_shortcut = + qtcommon::CreateWindowShortcutAction(this, QList{QKeySequence(Qt::Key_BracketRight)}); + if (lod_next_shortcut != nullptr) { + lod_next_shortcut->setObjectName("shortcutLodNext"); + connect(lod_next_shortcut, &QAction::triggered, this, &W3DViewMainWindow::selectNextLod); + } + auto *object_rotate_y_back_shortcut = + qtcommon::CreateWindowShortcutAction(this, QList{QKeySequence(Qt::Key_Down)}); + if (object_rotate_y_back_shortcut != nullptr) { + object_rotate_y_back_shortcut->setObjectName("shortcutObjectRotateYBack"); + connect(object_rotate_y_back_shortcut, &QAction::triggered, + this, &W3DViewMainWindow::toggleObjectRotateYBack); + } + auto *object_rotate_z_back_shortcut = + qtcommon::CreateWindowShortcutAction(this, QList{QKeySequence(Qt::Key_Left)}); + if (object_rotate_z_back_shortcut != nullptr) { + object_rotate_z_back_shortcut->setObjectName("shortcutObjectRotateZBack"); + connect(object_rotate_z_back_shortcut, &QAction::triggered, + this, &W3DViewMainWindow::toggleObjectRotateZBack); + } + auto *light_rotate_y_back_shortcut = + qtcommon::CreateWindowShortcutAction(this, QList{QKeySequence(Qt::CTRL | Qt::Key_Down)}); + if (light_rotate_y_back_shortcut != nullptr) { + light_rotate_y_back_shortcut->setObjectName("shortcutLightRotateYBack"); + connect(light_rotate_y_back_shortcut, &QAction::triggered, + this, &W3DViewMainWindow::toggleLightRotateYBack); + } + auto *light_rotate_z_back_shortcut = + qtcommon::CreateWindowShortcutAction(this, QList{QKeySequence(Qt::CTRL | Qt::Key_Left)}); + if (light_rotate_z_back_shortcut != nullptr) { + light_rotate_z_back_shortcut->setObjectName("shortcutLightRotateZBack"); + connect(light_rotate_z_back_shortcut, &QAction::triggered, + this, &W3DViewMainWindow::toggleLightRotateZBack); + } + for (int slot = 1; slot <= 9; ++slot) { + const Qt::Key key = static_cast(Qt::Key_1 + (slot - 1)); + auto *settings_shortcut = + qtcommon::CreateWindowShortcutAction(this, QList{QKeySequence(key)}); + if (settings_shortcut != nullptr) { + settings_shortcut->setObjectName(QString("shortcutQuickSettings%1").arg(slot)); + connect(settings_shortcut, &QAction::triggered, this, [this, slot]() { + loadQuickSettings(slot); + }); + } + } + auto *next_pane_shortcut = + qtcommon::CreateWindowShortcutAction(this, QList{QKeySequence(Qt::Key_F6)}); + if (next_pane_shortcut != nullptr) { + next_pane_shortcut->setObjectName("shortcutNextPane"); + connect(next_pane_shortcut, &QAction::triggered, this, [this]() { cyclePaneFocus(false); }); + } + auto *prev_pane_shortcut = + qtcommon::CreateWindowShortcutAction(this, QList{QKeySequence(Qt::SHIFT | Qt::Key_F6)}); + if (prev_pane_shortcut != nullptr) { + prev_pane_shortcut->setObjectName("shortcutPreviousPane"); + connect(prev_pane_shortcut, &QAction::triggered, this, [this]() { cyclePaneFocus(true); }); + } + + connect(_listMissingTexturesAction, &QAction::triggered, this, + &W3DViewMainWindow::listMissingTextures); + connect(_copyAssetsAction, &QAction::triggered, this, &W3DViewMainWindow::copyAssets); + connect(_addToLineupAction, &QAction::triggered, this, &W3DViewMainWindow::addToLineup); + statusBar()->showMessage("Ready"); + + _treeView = _ui->assetTreeView; + _treeModel = new QStandardItemModel(_treeView); + _treeModel->setHorizontalHeaderLabels(QStringList() << "Assets"); + _treeView->setModel(_treeModel); + _treeView->setHeaderHidden(false); + _treeView->setContextMenuPolicy(Qt::CustomContextMenu); + + connect(_treeView->selectionModel(), &QItemSelectionModel::currentChanged, + this, &W3DViewMainWindow::onCurrentChanged); + connect(_treeView, &QTreeView::customContextMenuRequested, + this, &W3DViewMainWindow::showTreeContextMenu); + + _viewport = _ui->viewport; + connect(_viewport, + &W3DViewport::animationStateChanged, + this, + &W3DViewMainWindow::refreshAnimationMenu); + connect(_viewport, + &W3DViewport::objectCameraReset, + this, + &W3DViewMainWindow::loadDefaultSettings); + refreshAnimationMenu(); + + _statusPolysLabel = _ui->statusPolysLabel; + _statusParticlesLabel = _ui->statusParticlesLabel; + _statusCameraLabel = _ui->statusCameraLabel; + _statusFramesLabel = _ui->statusFramesLabel; + _statusFpsLabel = _ui->statusFpsLabel; + _statusResolutionLabel = _ui->statusResolutionLabel; + + if (statusBar()) { + statusBar()->addPermanentWidget(_ui->permanentStatusPanel, 1); + } + + _statusTimer = new QTimer(this); + _statusTimer->setInterval(250); + connect(_statusTimer, &QTimer::timeout, this, &W3DViewMainWindow::updateStatusBar); + _statusTimer->start(); + + _ui->mainSplitter->setStretchFactor(0, 0); + _ui->mainSplitter->setStretchFactor(1, 1); + _ui->mainSplitter->setSizes({240, 800}); + + loadAppSettings(); + if (_animationToolbar) { + _changingAnimationToolbarForSelection = true; + _animationToolbar->hide(); + _changingAnimationToolbarForSelection = false; + } + loadDefaultSettings(); + if (_restrictAnimsAction) { + _restrictAnimsAction->setChecked(_restrictAnims); + } + if (_sortingAction) { + _sortingAction->setChecked(_sortingEnabled); + } + if (_invertBackfaceCullingAction) { + _invertBackfaceCullingAction->setChecked(ShaderClass::Is_Backface_Culling_Inverted()); + } + if (_wireframeAction && _viewport) { + _wireframeAction->setChecked(_viewport->isWireframeEnabled()); + } + if (_toolbarMainAction && _mainToolbar) { + const QSignalBlocker blocker(_toolbarMainAction); + _toolbarMainAction->setChecked(!_mainToolbar->isHidden()); + } + if (_toolbarObjectAction && _objectToolbar) { + const QSignalBlocker blocker(_toolbarObjectAction); + _toolbarObjectAction->setChecked(!_objectToolbar->isHidden()); + } + if (_toolbarAnimationAction && _animationToolbar) { + const QSignalBlocker blocker(_toolbarAnimationAction); + _toolbarAnimationAction->setChecked(!_animationToolbar->isHidden()); + } + if (_statusBarAction) { + _statusBarAction->setChecked(statusBar() && !statusBar()->isHidden()); + } + if (_fogAction && _viewport) { + _fogAction->setChecked(_viewport->isFogEnabled()); + } + if (_cameraResetOnDisplayAction) { + _cameraResetOnDisplayAction->setChecked(_autoResetCamera); + } + if (_cameraAnimateAction) { + _cameraAnimateAction->setChecked(_animateCamera); + } + if (_cameraBonePosXAction && _viewport) { + _cameraBonePosXAction->setChecked(_viewport->isCameraBonePosX()); + } + if (_exposePrelitAction) { + _exposePrelitAction->setChecked(WW3D::Expose_Prelit()); + } + if (_prelitGroup) { + const WW3D::PrelitModeEnum mode = WW3D::Get_Prelit_Mode(); + if (_prelitVertexAction && mode == WW3D::PRELIT_MODE_VERTEX) { + _prelitVertexAction->setChecked(true); + } else if (_prelitMultipassAction && mode == WW3D::PRELIT_MODE_LIGHTMAP_MULTI_PASS) { + _prelitMultipassAction->setChecked(true); + } else if (_prelitMultitexAction && mode == WW3D::PRELIT_MODE_LIGHTMAP_MULTI_TEXTURE) { + _prelitMultitexAction->setChecked(true); + } + } + rebuildAssetTree(); +} + +W3DViewMainWindow::~W3DViewMainWindow() +{ + stopAnimationSound(); + if (_viewport) { + // QObject deletes child actions and the central viewport only after this + // destructor body. Their relative child order is not an API guarantee, + // so the viewport must not emit menu-refresh signals after actions have + // begun disappearing in QMainWindow's base destructor. + disconnect(_viewport, nullptr, this, nullptr); + + // Release asset-manager-owned animation/render references while the + // complete window object is still alive. QObject deletes child widgets + // only after this destructor body has finished. + _viewport->clearAnimation(); + _viewport->setRenderObject(nullptr); + } + delete _ui; +} + +bool W3DViewMainWindow::openFilePath(const QString &path) +{ + const QFileInfo info(path); + if (!info.exists() || !info.isFile()) { + QMessageBox::warning(this, "W3DViewQt", QString("File not found:\n%1").arg(path)); + return false; + } + return loadAssetsFromFile(info.absoluteFilePath()); +} + +bool W3DViewMainWindow::loadSettingsPath(const QString &path) +{ + const QFileInfo info(path); + if (!_viewport || !info.exists() || !info.isFile()) { + return false; + } + + QSettings settings(info.absoluteFilePath(), QSettings::IniFormat); + settings.sync(); + settings.allKeys(); + if (settings.status() != QSettings::NoError) { + return false; + } + + applySettings(settings); + return settings.status() == QSettings::NoError; +} + +void W3DViewMainWindow::closeEvent(QCloseEvent *event) +{ + QSettings settings; + settings.setValue("Window/Geometry", saveGeometry()); + settings.setValue("Window/State", saveState()); + QMainWindow::closeEvent(event); + if (event && event->isAccepted()) { + QCoreApplication::quit(); + } +} + +void W3DViewMainWindow::dragEnterEvent(QDragEnterEvent *event) +{ + if (!event) { + return; + } + + const QMimeData *mime = event->mimeData(); + if (!mime || !mime->hasUrls()) { + return; + } + + const auto urls = mime->urls(); + for (const auto &url : urls) { + if (!url.isLocalFile()) { + continue; + } + const QString path = url.toLocalFile(); + if (path.endsWith(".w3d", Qt::CaseInsensitive)) { + event->acceptProposedAction(); + return; + } + } +} + +void W3DViewMainWindow::dropEvent(QDropEvent *event) +{ + if (!event) { + return; + } + + const QMimeData *mime = event->mimeData(); + if (!mime || !mime->hasUrls()) { + return; + } + + bool loaded_any = false; + const auto urls = mime->urls(); + for (const auto &url : urls) { + if (!url.isLocalFile()) { + continue; + } + const QString path = url.toLocalFile(); + if (!path.endsWith(".w3d", Qt::CaseInsensitive)) { + continue; + } + if (loadAssetsFromFile(path)) { + loaded_any = true; + } + } + + if (loaded_any) { + event->acceptProposedAction(); + } +} + +void W3DViewMainWindow::openFile() +{ + const QStringList paths = QFileDialog::getOpenFileNames( + this, + "Open W3D Assets", + _lastOpenedPath, + "W3D Assets (*.w3d);;All Files (*.*)"); + + if (paths.isEmpty()) { + return; + } + + QGuiApplication::setOverrideCursor(Qt::WaitCursor); + for (const QString &path : paths) { + loadAssetsFromFile(path); + } + QGuiApplication::restoreOverrideCursor(); +} + +void W3DViewMainWindow::openRecentFile() +{ + auto *action = qobject_cast(sender()); + if (!action) { + return; + } + + const QString path = action->data().toString(); + if (path.isEmpty()) { + return; + } + + const QFileInfo info(path); + if (!info.exists() || !info.isFile()) { + QMessageBox::warning(this, "W3DViewQt", QString("File not found:\n%1").arg(path)); + QSettings settings; + const QStringList files = qtcommon::RemoveRecentFile( + qtcommon::ReadRecentFiles(settings, QStringLiteral("recentFiles"), kMaxRecentFiles), + path); + qtcommon::WriteRecentFiles(settings, files, QStringLiteral("recentFiles"), kMaxRecentFiles); + updateRecentFilesMenu(); + return; + } + + if (!loadAssetsFromFile(path)) { + QSettings settings; + const QStringList files = qtcommon::RemoveRecentFile( + qtcommon::ReadRecentFiles(settings, QStringLiteral("recentFiles"), kMaxRecentFiles), + path); + qtcommon::WriteRecentFiles(settings, files, QStringLiteral("recentFiles"), kMaxRecentFiles); + updateRecentFilesMenu(); + } +} + +void W3DViewMainWindow::newFile() +{ + if (_viewport) { + _viewport->clearAnimation(); + _viewport->setRenderObject(nullptr); + _viewport->requestOneTimeCameraReset(); + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (asset_manager) { + asset_manager->Free_Assets(); + asset_manager->Load_Procedural_Textures(); + } + + // MFC preserves its last-open directory when creating a new document. + // Keeping it also makes the next Open dialog start in the same place. + _loadedFiles.clear(); + setWindowTitle("W3DViewQt"); + rebuildAssetTree(); + statusBar()->showMessage("Cleared assets."); +} + +void W3DViewMainWindow::openTexturePathsDialog() +{ + TexturePathDialog dialog(_texturePath1, _texturePath2, this); + if (dialog.exec() != QDialog::Accepted) { + return; + } + + setTexturePaths(dialog.path1(), dialog.path2()); +} + +void W3DViewMainWindow::loadSettingsFile() +{ + const QString path = QFileDialog::getOpenFileName( + this, + "Load Settings", + _lastOpenedPath, + "W3D Settings (*.dat *.ini);;All Files (*.*)"); + + if (path.isEmpty() || !_viewport) { + return; + } + + if (!loadSettingsPath(path)) { + QMessageBox::warning(this, "Load Settings", "Unable to read the selected settings file."); + return; + } + statusBar()->showMessage(QString("Loaded settings: %1").arg(QFileInfo(path).fileName())); +} + +void W3DViewMainWindow::saveSettingsFile() +{ + if (!_viewport) { + return; + } + + SaveSettingsDialog dialog(this); + if (dialog.exec() != QDialog::Accepted) { + return; + } + + const QString path = dialog.selectedPath(); + QSettings settings(path, QSettings::IniFormat); + writeSettings(settings, dialog.saveLighting(), dialog.saveBackground()); + settings.sync(); + + if (settings.status() != QSettings::NoError) { + QMessageBox::warning(this, "Save Settings", "Unable to write the selected settings file."); + return; + } + + statusBar()->showMessage(QString("Saved settings: %1").arg(QFileInfo(path).fileName())); +} + +void W3DViewMainWindow::loadQuickSettings(int slot) +{ + if (slot < 1 || slot > 9 || !_viewport) { + return; + } + + const QString path = + QDir(QCoreApplication::applicationDirPath()).filePath(QString("settings%1.dat").arg(slot)); + if (!QFileInfo::exists(path)) { + return; + } + + loadSettingsPath(path); +} + +void W3DViewMainWindow::cyclePaneFocus(bool reverse) +{ + if (!_treeView || !_viewport) { + return; + } + + QWidget *focused = QApplication::focusWidget(); + const bool tree_has_focus = focused == _treeView || _treeView->isAncestorOf(focused); + const bool viewport_has_focus = focused == _viewport || _viewport->isAncestorOf(focused); + if (tree_has_focus) { + _viewport->setFocus(); + return; + } + if (viewport_has_focus) { + _treeView->setFocus(); + return; + } + + if (reverse) { + _viewport->setFocus(); + } else { + _treeView->setFocus(); + } +} + +void W3DViewMainWindow::onCurrentChanged(const QModelIndex ¤t, const QModelIndex &previous) +{ + Q_UNUSED(previous); + updateSpecialMenu(current); + if (!_viewport) { + return; + } + + const int type_value = current.data(kRoleType).toInt(); + const bool is_render_object = type_value == static_cast(AssetNodeType::RenderObject); + const int class_id = current.data(kRoleClassId).toInt(); + const bool is_sound = is_render_object && class_id == RenderObjClass::CLASSID_SOUND; + const bool is_emitter = is_render_object && class_id == RenderObjClass::CLASSID_PARTICLEEMITTER; + const bool is_primitive = is_render_object && + (class_id == RenderObjClass::CLASSID_SPHERE || class_id == RenderObjClass::CLASSID_RING); + const bool is_animation = type_value == static_cast(AssetNodeType::Animation); + if (_selectionIsAnimation != is_animation && _animationToolbar) { + _changingAnimationToolbarForSelection = true; + if (_selectionIsAnimation) { + _showAnimationToolbar = _animationToolbar->isVisible(); + _animationToolbar->hide(); + } + _selectionIsAnimation = is_animation; + if (_selectionIsAnimation && _showAnimationToolbar) { + _animationToolbar->show(); + } + _changingAnimationToolbarForSelection = false; + } + RenderObjInfo selected_info; + if (is_render_object) { + const QString selected_name = current.data(kRoleName).toString(); + if (!selected_name.isEmpty()) { + const QByteArray selected_name_bytes = selected_name.toLatin1(); + selected_info = InspectRenderObj(selected_name_bytes.constData()); + } + } + const bool is_aggregate = is_render_object && selected_info.isAggregate; + const bool is_lod = is_render_object && selected_info.isRealLod && !selected_info.isAggregate; + const bool has_hierarchy = is_animation || + (is_render_object && !selected_info.hierarchyName.isEmpty()); + bool can_lineup = false; + if (is_render_object && _viewport) { + can_lineup = _viewport->canLineUpClass(class_id); + } + if (_copyAssetsAction) { + _copyAssetsAction->setEnabled(is_render_object); + } + if (_addToLineupAction) { + _addToLineupAction->setEnabled(can_lineup); + } + if (_editSoundObjectAction) { + _editSoundObjectAction->setEnabled(is_sound); + } + if (_exportSoundObjectAction) { + _exportSoundObjectAction->setEnabled(is_sound); + } + if (_exportEmitterAction) { + _exportEmitterAction->setEnabled(is_emitter); + } + if (_exportAggregateAction) { + _exportAggregateAction->setEnabled(is_aggregate); + } + if (_exportLodAction) { + _exportLodAction->setEnabled(is_lod); + } + if (_exportPrimitiveAction) { + _exportPrimitiveAction->setEnabled(is_primitive); + } + if (_editEmitterAction) { + _editEmitterAction->setEnabled(is_emitter); + } + if (_scaleEmitterAction) { + _scaleEmitterAction->setEnabled(is_emitter); + } + if (_editPrimitiveAction) { + _editPrimitiveAction->setEnabled(is_primitive); + } + if (_objectPropertiesAction) { + _objectPropertiesAction->setEnabled(is_render_object || is_animation); + } + if (_makeMovieAction) { + _makeMovieAction->setEnabled(is_animation); + } + _ui->actionImportFacialAnims->setEnabled(has_hierarchy); + if (type_value == static_cast(AssetNodeType::RenderObject)) { + _viewport->clearAnimation(); + + const QString name = current.data(kRoleName).toString(); + if (name.isEmpty()) { + return; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + return; + } + + const QByteArray name_bytes = name.toLatin1(); + RenderObjClass *object = asset_manager->Create_Render_Obj(name_bytes.constData()); + if (!object) { + statusBar()->showMessage(QString("Failed to create render object: %1").arg(name)); + return; + } + + SetHighestLod(object); + _viewport->setRenderObject(object); + object->Release_Ref(); + statusBar()->showMessage(QString("Showing: %1").arg(name)); + updateEmittersEditMenu(); + return; + } + + if (type_value == static_cast(AssetNodeType::Animation)) { + _viewport->clearAnimation(); + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + return; + } + + const QString animation_name = current.data(kRoleName).toString(); + if (animation_name.isEmpty()) { + return; + } + + QModelIndex render_index = current.parent(); + while (render_index.isValid() && + render_index.data(kRoleType).toInt() != static_cast(AssetNodeType::RenderObject)) { + render_index = render_index.parent(); + } + + if (!render_index.isValid()) { + return; + } + + const QString render_name = render_index.data(kRoleName).toString(); + if (render_name.isEmpty()) { + return; + } + + const QByteArray render_bytes = render_name.toLatin1(); + RenderObjClass *object = asset_manager->Create_Render_Obj(render_bytes.constData()); + if (!object) { + statusBar()->showMessage(QString("Failed to create render object: %1").arg(render_name)); + return; + } + + const QByteArray anim_bytes = animation_name.toLatin1(); + HAnimClass *animation = asset_manager->Get_HAnim(anim_bytes.constData()); + if (!animation) { + object->Release_Ref(); + statusBar()->showMessage(QString("Failed to load animation: %1").arg(animation_name)); + return; + } + + SetHighestLod(object); + _viewport->setRenderObject(object); + _viewport->setAnimation(animation); + playAnimationSound(); + object->Release_Ref(); + animation->Release_Ref(); + statusBar()->showMessage( + QString("Playing: %1 (%2)").arg(animation_name, render_name)); + updateEmittersEditMenu(); + return; + } + + if (type_value == static_cast(AssetNodeType::Material)) { + _viewport->clearAnimation(); + + const QString name = current.data(kRoleName).toString(); + const quintptr texture_ptr = current.data(kRolePointer).value(); + auto *texture = reinterpret_cast(texture_ptr); + if (!texture) { + statusBar()->showMessage(QString("Missing texture: %1").arg(name)); + return; + } + + auto *bitmap = new Bitmap2DObjClass(texture, 0.5f, 0.5f, true, false, false, true); + _viewport->setRenderObject(bitmap); + bitmap->Release_Ref(); + statusBar()->showMessage(QString("Showing material: %1").arg(name)); + updateEmittersEditMenu(); + return; + } + + _viewport->clearAnimation(); + _viewport->setRenderObject(nullptr); + updateEmittersEditMenu(); + statusBar()->showMessage("No asset selected."); +} + +void W3DViewMainWindow::updateSpecialMenu(const QModelIndex ¤t) +{ + if (!_objectMenuAction || !menuBar()) { + return; + } + + QMenu *desired_menu = nullptr; + const int type_value = current.data(kRoleType).toInt(); + const bool is_group = type_value == static_cast(AssetNodeType::Group); + + auto matches_group = [](const QString &text, const QString &label) { + return text == label || text.startsWith(label + " ("); + }; + + if (type_value == static_cast(AssetNodeType::Animation)) { + desired_menu = _animationMenu; + } else if (type_value == static_cast(AssetNodeType::RenderObject) || is_group) { + const QString group_label = ResolveGroupLabel(_treeModel, current, is_group); + if (matches_group(group_label, "H-LOD")) { + desired_menu = _lodMenu; + } else if (matches_group(group_label, "Hierarchy")) { + desired_menu = _hierarchyMenu; + } else if (matches_group(group_label, "Aggregate")) { + desired_menu = _aggregateMenu; + } + } + + QAction *desired_action = desired_menu ? desired_menu->menuAction() : nullptr; + if (_specialMenuAction && _specialMenuAction != desired_action) { + menuBar()->removeAction(_specialMenuAction); + } + if (desired_action && !menuBar()->actions().contains(desired_action)) { + menuBar()->insertMenu(_ui->lightingMenu->menuAction(), desired_menu); + } + _specialMenuAction = desired_action; +} + +void W3DViewMainWindow::updateEmittersEditMenu() +{ + if (!_emittersEditMenu) { + return; + } + + _emittersEditMenu->clear(); + + QStringList names; + if (_viewport) { + if (auto *render_obj = _viewport->currentRenderObject()) { + CollectEmitterNames(*render_obj, names); + } + } + + if (names.isEmpty()) { + auto *empty_action = _emittersEditMenu->addAction("(No Emitters)"); + empty_action->setEnabled(false); + return; + } + + names.sort(Qt::CaseInsensitive); + for (const auto &name : names) { + auto *action = _emittersEditMenu->addAction(name); + connect(action, &QAction::triggered, this, [this, name]() { editEmitterByName(name); }); + } +} + +void W3DViewMainWindow::refreshAnimationMenu() +{ + const bool has_anim = _viewport && _viewport->hasAnimation(); + const W3DViewport::AnimationState state = + has_anim ? _viewport->animationState() : W3DViewport::AnimationState::Stopped; + if (_animationPlayAction) { + _animationPlayAction->setEnabled(has_anim); + _animationPlayAction->setChecked(has_anim && state == W3DViewport::AnimationState::Playing); + } + if (_animationPauseAction) { + _animationPauseAction->setEnabled(has_anim); + _animationPauseAction->setChecked(has_anim && state == W3DViewport::AnimationState::Paused); + } + if (_animationStopAction) { + _animationStopAction->setEnabled(has_anim); + } + if (_animationStepBackAction) { + _animationStepBackAction->setEnabled(has_anim); + } + if (_animationStepForwardAction) { + _animationStepForwardAction->setEnabled(has_anim); + } +} + +void W3DViewMainWindow::refreshAggregateMenu() +{ + if (!_aggregateBindSubobjectAction) { + return; + } + const bool bound = _viewport && _viewport->isSubobjectLodBound(); + _aggregateBindSubobjectAction->setChecked(bound); +} + +void W3DViewMainWindow::refreshLodMenu() +{ + if (!_viewport) { + if (_lodPrevAction) { + _lodPrevAction->setEnabled(false); + } + if (_lodNextAction) { + _lodNextAction->setEnabled(false); + } + return; + } + + if (_lodIncludeNullAction) { + _lodIncludeNullAction->setChecked(_viewport->isNullLodIncluded()); + } + if (_lodAutoSwitchAction) { + _lodAutoSwitchAction->setChecked(_viewport->isLodAutoSwitchingEnabled()); + } + + int level = 0; + int count = 0; + const bool has_lod = _viewport->currentLodInfo(level, count); + if (_lodPrevAction) { + _lodPrevAction->setEnabled(has_lod && level > 0); + } + if (_lodNextAction) { + _lodNextAction->setEnabled(has_lod && (level + 1) < count); + } +} + +bool W3DViewMainWindow::commitEmitterDefinition(const ParticleEmitterDefClass &definition, + const QString ®isteredName, + bool reloadCurrentObject, + bool attachedToAggregate) +{ + const char *definition_name = definition.Get_Name(); + const QString updated_name = definition_name ? QString::fromLatin1(definition_name) : QString(); + QString displayed_name; + if (reloadCurrentObject && _viewport) { + if (RenderObjClass *displayed_object = _viewport->currentRenderObject()) { + const char *name = displayed_object->Get_Name(); + if (name) { + displayed_name = QString::fromLatin1(name); + } + } + } + + const bool is_rename = !registeredName.isEmpty() && + registeredName.compare(updated_name, Qt::CaseInsensitive) != 0; + if (attachedToAggregate && is_rename) { + QMessageBox::warning( + this, + "Apply Emitter", + "This emitter is attached to an aggregate. Rename it only after removing or rebinding " + "the aggregate reference; other property changes can still be applied here."); + return false; + } + + QString error_message; + if (!UpdateEmitterPrototype(definition, registeredName, &error_message)) { + QMessageBox::warning(this, + "Apply Emitter", + error_message.isEmpty() ? "Failed to register emitter prototype." + : error_message); + return false; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (reloadCurrentObject && _viewport && asset_manager && !displayed_name.isEmpty()) { + const QByteArray displayed_bytes = displayed_name.toLatin1(); + if (RenderObjClass *object = asset_manager->Create_Render_Obj(displayed_bytes.constData())) { + _viewport->clearAnimation(); + _viewport->setRenderObject(object); + object->Release_Ref(); + } else { + QMessageBox::warning(this, + "Apply Emitter", + "The emitter was registered, but the displayed object could not " + "be reloaded."); + } + } else if (_viewport && asset_manager && !updated_name.isEmpty()) { + const QByteArray updated_bytes = updated_name.toLatin1(); + if (RenderObjClass *object = asset_manager->Create_Render_Obj(updated_bytes.constData())) { + _viewport->clearAnimation(); + _viewport->setRenderObject(object); + object->Release_Ref(); + } else { + QMessageBox::warning(this, + "Apply Emitter", + "The emitter was registered, but its preview could not be reloaded."); + } + } + + rebuildAssetTree(); + if (!reloadCurrentObject && _treeModel && _treeView) { + const QModelIndex index = FindRenderObjectIndex(_treeModel, + updated_name, + RenderObjClass::CLASSID_PARTICLEEMITTER); + if (index.isValid()) { + ExpandParentChain(_treeView, index.parent()); + _treeView->setCurrentIndex(index); + _treeView->scrollTo(index); + } + } + + statusBar()->showMessage(QString("Applied emitter: %1").arg(updated_name)); + return true; +} + +void W3DViewMainWindow::editEmitterByName(const QString &name) +{ + if (name.isEmpty()) { + return; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + QMessageBox::warning(this, "Edit Emitter", "WW3D asset manager is not available."); + return; + } + + const QByteArray name_bytes = name.toLatin1(); + RenderObjClass *render_obj = asset_manager->Create_Render_Obj(name_bytes.constData()); + if (!render_obj) { + QMessageBox::warning(this, "Edit Emitter", "Failed to load emitter."); + return; + } + + if (render_obj->Class_ID() != RenderObjClass::CLASSID_PARTICLEEMITTER) { + render_obj->Release_Ref(); + QMessageBox::warning(this, "Edit Emitter", "Selected object is not an emitter."); + return; + } + + auto *emitter = static_cast(render_obj); + ParticleEmitterDefClass *definition = emitter->Build_Definition(); + emitter->Release_Ref(); + if (!definition) { + QMessageBox::warning(this, "Edit Emitter", "Failed to load emitter definition."); + return; + } + + bool attached_to_aggregate = false; + if (_viewport) { + if (RenderObjClass *displayed_object = _viewport->currentRenderObject()) { + const char *displayed_name = displayed_object->Get_Name(); + if (displayed_name && displayed_name[0]) { + PrototypeClass *displayed_prototype = asset_manager->Find_Prototype(displayed_name); + attached_to_aggregate = + dynamic_cast(displayed_prototype) != nullptr; + } + } + } + + EmitterEditDialog dialog(*definition, this); + delete definition; + dialog.setApplyHandler( + [this, attached_to_aggregate](const ParticleEmitterDefClass &updated, + const QString ®isteredName) { + return commitEmitterDefinition(updated, + registeredName, + true, + attached_to_aggregate); + }, + dialog.originalName()); + dialog.exec(); +} + +void W3DViewMainWindow::updateStatusBar() +{ + if (!_viewport) { + return; + } + + int polys = 0; + int particles = 0; + if (auto *render_obj = _viewport->currentRenderObject()) { + polys = render_obj->Get_Num_Polys(); + particles = CountParticles(render_obj); + } + + if (_statusPolysLabel) { + _statusPolysLabel->setText(QString("Polys %1").arg(polys)); + } + if (_statusParticlesLabel) { + _statusParticlesLabel->setText(QString("Particles %1").arg(particles)); + } + if (_statusCameraLabel) { + _statusCameraLabel->setText(QString("Camera %1").arg(_viewport->cameraDistance(), 0, 'f', 3)); + } + + int current_frame = 0; + int total_frames = 0; + float fps = 0.0f; + const bool has_anim = _viewport->animationStatus(current_frame, total_frames, fps); + if (_statusFramesLabel) { + const int max_frame = total_frames > 0 ? total_frames - 1 : 0; + const int display_frame = has_anim ? current_frame : 0; + const float display_fps = has_anim ? fps : 0.0f; + _statusFramesLabel->setText( + QString("Frame %1/%2 at %3 fps") + .arg(display_frame) + .arg(max_frame) + .arg(display_fps, 0, 'f', 2)); + } + + if (_statusFpsLabel) { + const float frame_ms = _viewport->averageFrameMilliseconds(); + if (frame_ms > 0.0f) { + _statusFpsLabel->setText(QString("Clocks: %1").arg(frame_ms, 0, 'f', 2)); + } else { + _statusFpsLabel->setText(QString()); + } + } + + if (_statusResolutionLabel) { + _statusResolutionLabel->setText(QString(" %1 x %2 ").arg(_viewport->width()).arg(_viewport->height())); + } +} + +void W3DViewMainWindow::toggleWireframe(bool enabled) +{ + if (_viewport) { + _viewport->setWireframeEnabled(enabled); + } +} + +void W3DViewMainWindow::toggleSorting(bool enabled) +{ + WW3D::_Invalidate_Mesh_Cache(); + WW3D::Enable_Sorting(enabled); + _sortingEnabled = enabled; + + QSettings settings; + settings.setValue("Config/EnableSorting", enabled); +} + +void W3DViewMainWindow::toggleAutoExpandAssetTree(bool enabled) +{ + _autoExpandAssetTree = enabled; + + QSettings settings; + settings.setValue("Config/AutoExpandAssetTree", enabled); + + if (!_treeView || !_treeModel) { + return; + } + + auto *root = _treeModel->invisibleRootItem(); + if (!root) { + return; + } + + const int count = root->rowCount(); + for (int index = 0; index < count; ++index) { + auto *item = root->child(index); + if (!item) { + continue; + } + _treeView->setExpanded(item->index(), enabled); + } +} + +void W3DViewMainWindow::toggleBackfaceCulling(bool inverted) +{ + ShaderClass::Invert_Backface_Culling(inverted); + QSettings settings; + settings.setValue("Config/InvertBackfaceCulling", inverted); +} + +void W3DViewMainWindow::toggleRestrictAnims(bool enabled) +{ + if (_restrictAnims == enabled) { + return; + } + + _restrictAnims = enabled; + rebuildAssetTree(); +} + +void W3DViewMainWindow::toggleStatusBar(bool visible) +{ + if (statusBar()) { + statusBar()->setVisible(visible); + } +} + +void W3DViewMainWindow::toggleMainToolbar(bool visible) +{ + if (_mainToolbar) { + _mainToolbar->setVisible(visible); + } +} + +void W3DViewMainWindow::toggleObjectToolbar(bool visible) +{ + if (_objectToolbar) { + _objectToolbar->setVisible(visible); + } +} + +void W3DViewMainWindow::toggleAnimationToolbar(bool visible) +{ + _showAnimationToolbar = visible; + if (_animationToolbar) { + _animationToolbar->setVisible(visible); + } +} + +void W3DViewMainWindow::setAmbientLight() +{ + if (!_viewport) { + return; + } + + ColorLightDialog dialog( + "Ambient Light", + _viewport->ambientLight(), + [this](const Vector3 &color) { + if (_viewport) { + _viewport->setAmbientLight(color); + } + }, + this); + dialog.exec(); +} + +void W3DViewMainWindow::setSceneLight() +{ + if (!_viewport) { + return; + } + + SceneLightDialog dialog(*_viewport, this); + dialog.exec(); +} + +void W3DViewMainWindow::increaseAmbientLight() +{ + if (!_viewport) { + return; + } + + Vector3 color = _viewport->ambientLight(); + AdjustLightIntensity(color, 0.05f); + _viewport->setAmbientLight(color); +} + +void W3DViewMainWindow::decreaseAmbientLight() +{ + if (!_viewport) { + return; + } + + Vector3 color = _viewport->ambientLight(); + AdjustLightIntensity(color, -0.05f); + _viewport->setAmbientLight(color); +} + +void W3DViewMainWindow::increaseSceneLight() +{ + if (!_viewport) { + return; + } + + Vector3 diffuse = _viewport->sceneLightDiffuse(); + Vector3 specular = _viewport->sceneLightSpecular(); + AdjustLightIntensity(diffuse, 0.05f); + AdjustLightIntensity(specular, 0.05f); + _viewport->setSceneLightDiffuse(diffuse); + _viewport->setSceneLightSpecular(specular); +} + +void W3DViewMainWindow::decreaseSceneLight() +{ + if (!_viewport) { + return; + } + + Vector3 diffuse = _viewport->sceneLightDiffuse(); + Vector3 specular = _viewport->sceneLightSpecular(); + AdjustLightIntensity(diffuse, -0.05f); + AdjustLightIntensity(specular, -0.05f); + _viewport->setSceneLightDiffuse(diffuse); + _viewport->setSceneLightSpecular(specular); +} + +void W3DViewMainWindow::killSceneLight() +{ + if (!_viewport) { + return; + } + + _viewport->setSceneLightColor(Vector3(0.0f, 0.0f, 0.0f)); +} + +void W3DViewMainWindow::toggleLightRotateY(bool enabled) +{ + if (!_viewport) { + return; + } + + int flags = _viewport->lightRotationFlags(); + if (enabled) { + flags |= W3DViewport::RotateY; + flags &= ~W3DViewport::RotateYBack; + } else { + flags &= ~W3DViewport::RotateY; + } + _viewport->setLightRotationFlags(flags); +} + +void W3DViewMainWindow::toggleLightRotateYBack() +{ + if (!_viewport) { + return; + } + + int flags = _viewport->lightRotationFlags(); + flags ^= W3DViewport::RotateYBack; + flags &= ~W3DViewport::RotateY; + _viewport->setLightRotationFlags(flags); + if (_lightRotateYAction) { + const QSignalBlocker blocker(_lightRotateYAction); + _lightRotateYAction->setChecked((flags & W3DViewport::RotateY) != 0); + } +} + +void W3DViewMainWindow::toggleLightRotateZ(bool enabled) +{ + if (!_viewport) { + return; + } + + int flags = _viewport->lightRotationFlags(); + if (enabled) { + flags |= W3DViewport::RotateZ; + flags &= ~W3DViewport::RotateZBack; + } else { + flags &= ~W3DViewport::RotateZ; + } + _viewport->setLightRotationFlags(flags); +} + +void W3DViewMainWindow::toggleLightRotateZBack() +{ + if (!_viewport) { + return; + } + + int flags = _viewport->lightRotationFlags(); + flags ^= W3DViewport::RotateZBack; + flags &= ~W3DViewport::RotateZ; + _viewport->setLightRotationFlags(flags); + if (_lightRotateZAction) { + const QSignalBlocker blocker(_lightRotateZAction); + _lightRotateZAction->setChecked((flags & W3DViewport::RotateZ) != 0); + } +} + +void W3DViewMainWindow::toggleExposePrelit(bool enabled) +{ + WW3D::Expose_Prelit(enabled); +} + +void W3DViewMainWindow::setPrelitVertex() +{ + if (WW3D::Get_Prelit_Mode() == WW3D::PRELIT_MODE_VERTEX) { + return; + } + + WW3D::Set_Prelit_Mode(WW3D::PRELIT_MODE_VERTEX); + reloadLightmapModels(); + reloadDisplayedObject(); +} + +void W3DViewMainWindow::setPrelitMultipass() +{ + if (WW3D::Get_Prelit_Mode() == WW3D::PRELIT_MODE_LIGHTMAP_MULTI_PASS) { + return; + } + + WW3D::Set_Prelit_Mode(WW3D::PRELIT_MODE_LIGHTMAP_MULTI_PASS); + reloadLightmapModels(); + reloadDisplayedObject(); +} + +void W3DViewMainWindow::setPrelitMultitex() +{ + if (WW3D::Get_Prelit_Mode() == WW3D::PRELIT_MODE_LIGHTMAP_MULTI_TEXTURE) { + return; + } + + WW3D::Set_Prelit_Mode(WW3D::PRELIT_MODE_LIGHTMAP_MULTI_TEXTURE); + reloadLightmapModels(); + reloadDisplayedObject(); +} + +void W3DViewMainWindow::setBackgroundColor() +{ + if (!_viewport) { + return; + } + + ColorLightDialog dialog( + "Background Color", + _viewport->backgroundColor(), + [this](const Vector3 &color) { + if (_viewport) { + _viewport->setBackgroundColor(color); + } + }, + this); + dialog.exec(); +} + +void W3DViewMainWindow::setBackgroundBitmap() +{ + if (!_viewport) { + return; + } + + BackgroundBitmapDialog dialog(_viewport->backgroundBitmap(), this); + if (dialog.exec() != QDialog::Accepted) { + return; + } + + const QString path = dialog.selectedPath(); + _viewport->setBackgroundBitmap(path); + statusBar()->showMessage(path.isEmpty() + ? QStringLiteral("Background bitmap cleared") + : QString("Background bitmap: %1").arg(QFileInfo(path).fileName())); +} + +void W3DViewMainWindow::toggleFog(bool enabled) +{ + if (_viewport) { + _viewport->setFogEnabled(enabled); + } +} + +void W3DViewMainWindow::setCameraFront() +{ + if (_viewport) { + _viewport->setCameraPosition(W3DViewport::CameraPosition::Front); + } +} + +void W3DViewMainWindow::setCameraBack() +{ + if (_viewport) { + _viewport->setCameraPosition(W3DViewport::CameraPosition::Back); + } +} + +void W3DViewMainWindow::setCameraLeft() +{ + if (_viewport) { + _viewport->setCameraPosition(W3DViewport::CameraPosition::Left); + } +} + +void W3DViewMainWindow::setCameraRight() +{ + if (_viewport) { + _viewport->setCameraPosition(W3DViewport::CameraPosition::Right); + } +} + +void W3DViewMainWindow::setCameraTop() +{ + if (_viewport) { + _viewport->setCameraPosition(W3DViewport::CameraPosition::Top); + } +} + +void W3DViewMainWindow::setCameraBottom() +{ + if (_viewport) { + _viewport->setCameraPosition(W3DViewport::CameraPosition::Bottom); + } +} + +void W3DViewMainWindow::resetCamera() +{ + if (_viewport) { + _viewport->resetCamera(); + } +} + +void W3DViewMainWindow::setCameraRotateX(bool enabled) +{ + if (!_viewport) { + return; + } + + if (enabled) { + _viewport->setAllowedCameraRotation(W3DViewport::CameraRotation::OnlyX); + if (_cameraRotateYAction) { + _cameraRotateYAction->setChecked(false); + } + if (_cameraRotateZAction) { + _cameraRotateZAction->setChecked(false); + } + } else if (_viewport->allowedCameraRotation() == W3DViewport::CameraRotation::OnlyX) { + _viewport->setAllowedCameraRotation(W3DViewport::CameraRotation::Free); + } +} + +void W3DViewMainWindow::setCameraRotateY(bool enabled) +{ + if (!_viewport) { + return; + } + + if (enabled) { + _viewport->setAllowedCameraRotation(W3DViewport::CameraRotation::OnlyY); + if (_cameraRotateXAction) { + _cameraRotateXAction->setChecked(false); + } + if (_cameraRotateZAction) { + _cameraRotateZAction->setChecked(false); + } + } else if (_viewport->allowedCameraRotation() == W3DViewport::CameraRotation::OnlyY) { + _viewport->setAllowedCameraRotation(W3DViewport::CameraRotation::Free); + } +} + +void W3DViewMainWindow::setCameraRotateZ(bool enabled) +{ + if (!_viewport) { + return; + } + + if (enabled) { + _viewport->setAllowedCameraRotation(W3DViewport::CameraRotation::OnlyZ); + if (_cameraRotateXAction) { + _cameraRotateXAction->setChecked(false); + } + if (_cameraRotateYAction) { + _cameraRotateYAction->setChecked(false); + } + } else if (_viewport->allowedCameraRotation() == W3DViewport::CameraRotation::OnlyZ) { + _viewport->setAllowedCameraRotation(W3DViewport::CameraRotation::Free); + } +} + +void W3DViewMainWindow::toggleCameraAnimate(bool enabled) +{ + _animateCamera = enabled; + if (_viewport) { + _viewport->setCameraAnimationEnabled(enabled); + if (!enabled) { + _viewport->resetCamera(); + } + } + + QSettings settings; + settings.setValue("Config/AnimateCamera", enabled); +} + +void W3DViewMainWindow::toggleCameraResetOnDisplay(bool enabled) +{ + _autoResetCamera = enabled; + if (_viewport) { + _viewport->setAutoResetEnabled(enabled); + } + + QSettings settings; + settings.setValue("Config/ResetCamera", enabled); +} + +void W3DViewMainWindow::toggleCameraBonePosX(bool enabled) +{ + if (_viewport) { + _viewport->setCameraBonePosX(enabled); + } +} + +void W3DViewMainWindow::openCameraSettings() +{ + if (!_viewport) { + return; + } + + CameraSettingsDialog dialog(_viewport, this); + if (dialog.exec() != QDialog::Accepted) { + return; + } + + const bool manual_fov = dialog.isManualFovEnabled(); + const bool manual_clip = dialog.isManualClipPlanesEnabled(); + + if (manual_fov) { + _viewport->setManualFovEnabled(true); + _viewport->setCameraFovDegrees(dialog.hfovDegrees(), dialog.vfovDegrees()); + } else { + _viewport->setManualFovEnabled(false); + _viewport->resetFov(); + } + + _viewport->setManualClipPlanesEnabled(manual_clip); + _viewport->setCameraClipPlanes(dialog.nearClip(), dialog.farClip()); + + double hfov_deg = 0.0; + double vfov_deg = 0.0; + _viewport->cameraFovDegrees(hfov_deg, vfov_deg); + float znear = 0.0f; + float zfar = 0.0f; + _viewport->cameraClipPlanes(znear, zfar); + + QSettings settings; + settings.setValue("Config/UseManualFOV", manual_fov); + settings.setValue("Config/UseManualClipPlanes", manual_clip); + settings.setValue("Config/hfov", hfov_deg * kDegToRad); + settings.setValue("Config/vfov", vfov_deg * kDegToRad); + settings.setValue("Config/znear", znear); + settings.setValue("Config/zfar", zfar); + _viewport->resetCamera(); +} + +void W3DViewMainWindow::openCameraDistance() +{ + if (!_viewport) { + return; + } + + CameraDistanceDialog dialog(_viewport->cameraDistance(), this); + if (dialog.exec() != QDialog::Accepted) { + return; + } + + _viewport->setCameraDistance(dialog.distance()); +} + +void W3DViewMainWindow::copyScreenSize() +{ + if (!_viewport) { + return; + } + + const float size = _viewport->currentScreenSize(); + if (size <= 0.0f) { + statusBar()->showMessage("No render object to measure."); + return; + } + + const QString text = QString("MaxScreenSize=%1").arg(size, 0, 'f', 6); + if (auto *clipboard = QGuiApplication::clipboard()) { + clipboard->setText(text); + statusBar()->showMessage("Copied screen size to clipboard."); + } +} + +void W3DViewMainWindow::changeResolution() +{ + if (!_viewport) { + return; + } + + QSettings settings; + const ResolutionDialog::Mode preferred_mode( + settings.value("Config/DeviceWidth", 0).toInt(), + settings.value("Config/DeviceHeight", 0).toInt(), + settings.value("Config/DeviceBitsPerPix", 32).toInt()); + ResolutionDialog dialog(preferred_mode, isFullScreen(), this); + if (dialog.exec() != QDialog::Accepted) { + return; + } + + const int width = dialog.selectedWidth(); + const int height = dialog.selectedHeight(); + const int bits_per_pixel = dialog.selectedBitsPerPixel(); + if (width <= 0 || height <= 0 || bits_per_pixel <= 0) { + QMessageBox::warning(this, "Resolution", "Select a valid display resolution."); + return; + } + + const bool fullscreen = dialog.fullscreen(); + const bool was_fullscreen = isFullScreen(); + + // Leaving borderless mode exposes the restored viewport size first, while + // the viewport still retains its previous fullscreen render preference. + // Entering does the inverse so a failed device reset cannot strand the Qt + // window in a state the renderer did not accept. + if (was_fullscreen && !fullscreen) { + showNormal(); + } + + if (!_viewport->applyResolution(width, height, bits_per_pixel, fullscreen)) { + if (was_fullscreen && !fullscreen) { + showFullScreen(); + } + QMessageBox::warning(this, "Resolution", "The selected display mode could not be applied."); + return; + } + + if (!was_fullscreen && fullscreen) { + showFullScreen(); + } + + int applied_width = 0; + int applied_height = 0; + int applied_bits_per_pixel = 0; + bool applied_windowed = true; + WW3D::Get_Device_Resolution( + applied_width, applied_height, applied_bits_per_pixel, applied_windowed); + + // Keep the selected mode as the next borderless preference. In windowed + // mode the active render surface follows the widget and is intentionally + // allowed to differ from this stored preference. + settings.setValue("Config/DeviceWidth", width); + settings.setValue("Config/DeviceHeight", height); + settings.setValue("Config/DeviceBitsPerPix", bits_per_pixel); + settings.setValue("Config/Windowed", fullscreen ? 0 : 1); + statusBar()->showMessage(QString("Display mode: %1 x %2, %3 bpp%4") + .arg(applied_width) + .arg(applied_height) + .arg(applied_bits_per_pixel) + .arg(fullscreen ? " borderless fullscreen" : " windowed")); +} + +void W3DViewMainWindow::openGammaDialog() +{ + if (_enableGammaAction && !_enableGammaAction->isChecked()) { + QMessageBox::warning(this, "Gamma", "Gamma is disabled.\nEnable it in the File menu."); + return; + } + + GammaDialog dialog(this); + dialog.exec(); +} + +void W3DViewMainWindow::toggleGammaCorrection(bool enabled) +{ + QSettings settings; + settings.setValue("Config/EnableGamma", enabled ? 1 : 0); + + if (enabled) { + int gamma = settings.value("Config/Gamma", 10).toInt(); + if (gamma < 10) { + gamma = 10; + } + if (gamma > 30) { + gamma = 30; + } + DX8Wrapper::Set_Gamma(gamma / 10.0f, 0.0f, 1.0f); + } else { + DX8Wrapper::Set_Gamma(1.0f, 0.0f, 1.0f); + } +} + +void W3DViewMainWindow::toggleMungeSortOnLoad(bool enabled) +{ + WW3D::Enable_Munge_Sort_On_Load(enabled); + QSettings settings; + settings.setValue("Config/MungeSortOnLoad", enabled ? 1 : 0); +} + +void W3DViewMainWindow::openBackgroundObjectDialog() +{ + if (!_viewport) { + return; + } + + BackgroundObjectDialog dialog(_viewport->backgroundObjectName(), this); + if (dialog.exec() != QDialog::Accepted) { + return; + } + + const QString name = dialog.selectedName(); + _viewport->setBackgroundObjectName(name); + if (name.isEmpty()) { + statusBar()->showMessage("Background object cleared."); + } else { + statusBar()->showMessage(QString("Background object: %1").arg(name)); + } +} + +void W3DViewMainWindow::captureScreenshot() +{ + if (!_viewport) { + return; + } + + const QString base = QDir(QCoreApplication::applicationDirPath()).filePath("ScreenShot"); + const int screenshot_number = _viewport->captureScreenshot(base); + if (screenshot_number <= 0) { + statusBar()->showMessage("Screen capture failed."); + return; + } + + const QString filename = QString("%1%2.tga") + .arg(base) + .arg(screenshot_number, 2, 10, QLatin1Char('0')); + statusBar()->showMessage(QString("Saved screenshot: %1").arg(filename)); +} + +void W3DViewMainWindow::makeMovie() +{ + if (!_viewport || !_treeView) { + return; + } + + const QModelIndex current = _treeView->currentIndex(); + if (!current.isValid() || + current.data(kRoleType).toInt() != static_cast(AssetNodeType::Animation)) { + QMessageBox::information(this, "Make Movie", "Select an animation to capture."); + return; + } + + if (!_viewport->hasAnimation()) { + QMessageBox::information(this, "Make Movie", "No animation is available for capture."); + return; + } + + const QString previous_directory = QDir::currentPath(); + if (!QDir::setCurrent(QCoreApplication::applicationDirPath())) { + QMessageBox::warning(this, + "Make Movie", + "Unable to use the application directory for movie capture."); + return; + } + + QGuiApplication::setOverrideCursor(Qt::BlankCursor); + QString error; + const bool ok = _viewport->captureMovie(QStringLiteral("Grab"), 30.0f, &error); + QGuiApplication::restoreOverrideCursor(); + + const bool directory_restored = QDir::setCurrent(previous_directory); + if (!directory_restored) { + if (!error.isEmpty()) { + error += '\n'; + } + error += "The previous working directory could not be restored."; + } + + if (!ok || !directory_restored) { + const QString message = error.isEmpty() ? "Movie capture failed." : error; + QMessageBox::warning(this, "Make Movie", message); + return; + } + + statusBar()->showMessage("Movie capture complete."); +} + +void W3DViewMainWindow::selectPrevAsset() +{ + if (!_treeView) { + return; + } + + const QModelIndex current = _treeView->currentIndex(); + if (!current.isValid()) { + return; + } + + const QModelIndex prev = _treeModel->index(current.row() - 1, current.column(), current.parent()); + if (prev.isValid()) { + _treeView->setCurrentIndex(prev); + } +} + +void W3DViewMainWindow::selectNextAsset() +{ + if (!_treeView) { + return; + } + + const QModelIndex current = _treeView->currentIndex(); + if (!current.isValid()) { + return; + } + + const QModelIndex next = _treeModel->index(current.row() + 1, current.column(), current.parent()); + if (next.isValid()) { + _treeView->setCurrentIndex(next); + } +} + +void W3DViewMainWindow::showTreeContextMenu(const QPoint &pos) +{ + if (!_treeView || !_viewport) { + return; + } + + const QModelIndex index = _treeView->indexAt(pos); + if (!index.isValid()) { + return; + } + + const int type_value = index.data(kRoleType).toInt(); + const bool is_group = type_value == static_cast(AssetNodeType::Group); + if (!is_group) { + _treeView->setCurrentIndex(index); + } + auto matches_group = [](const QString &text, const QString &label) { + return text == label || text.startsWith(label + " ("); + }; + + if (type_value == static_cast(AssetNodeType::Animation)) { + QMenu menu(this); + auto *play_action = menu.addAction("Play"); + connect(play_action, &QAction::triggered, this, &W3DViewMainWindow::startAnimation); + auto *pause_action = menu.addAction("Pause"); + connect(pause_action, &QAction::triggered, this, &W3DViewMainWindow::pauseAnimation); + auto *stop_action = menu.addAction("Stop"); + connect(stop_action, &QAction::triggered, this, &W3DViewMainWindow::stopAnimation); + menu.addSeparator(); + auto *step_back_action = menu.addAction("Step Back"); + connect(step_back_action, &QAction::triggered, this, &W3DViewMainWindow::stepAnimationBackward); + auto *step_forward_action = menu.addAction("Step Forward"); + connect(step_forward_action, &QAction::triggered, this, &W3DViewMainWindow::stepAnimationForward); + menu.addSeparator(); + auto *settings_action = menu.addAction("Settings"); + connect(settings_action, &QAction::triggered, this, &W3DViewMainWindow::openAnimationSettings); + menu.addSeparator(); + auto *advanced_action = menu.addAction("Advanced..."); + connect(advanced_action, &QAction::triggered, this, &W3DViewMainWindow::openAdvancedAnimation); + + const bool has_anim = _viewport->hasAnimation(); + play_action->setEnabled(has_anim); + pause_action->setEnabled(has_anim); + stop_action->setEnabled(has_anim); + step_back_action->setEnabled(has_anim); + step_forward_action->setEnabled(has_anim); + + menu.exec(_treeView->viewport()->mapToGlobal(pos)); + return; + } + + if (type_value != static_cast(AssetNodeType::RenderObject) && !is_group) { + return; + } + + const QString group_label = ResolveGroupLabel(_treeModel, index, is_group); + + if (matches_group(group_label, "H-LOD")) { + QMenu menu(this); + auto *record_action = menu.addAction("Record Screen Area"); + connect(record_action, &QAction::triggered, this, &W3DViewMainWindow::recordLodScreenArea); + + auto *include_null_action = menu.addAction("Include NULL Object"); + include_null_action->setCheckable(true); + include_null_action->setChecked(_viewport->isNullLodIncluded()); + connect(include_null_action, &QAction::triggered, this, &W3DViewMainWindow::toggleLodIncludeNull); + + menu.addSeparator(); + + auto *prev_action = menu.addAction("Prev Level"); + connect(prev_action, &QAction::triggered, this, &W3DViewMainWindow::selectPrevLod); + auto *next_action = menu.addAction("Next Level"); + connect(next_action, &QAction::triggered, this, &W3DViewMainWindow::selectNextLod); + + int level = 0; + int count = 0; + if (_viewport->currentLodInfo(level, count)) { + prev_action->setEnabled(level > 0); + next_action->setEnabled(level + 1 < count); + } + + menu.addSeparator(); + + auto *auto_switch_action = menu.addAction("Auto Switching"); + auto_switch_action->setCheckable(true); + auto_switch_action->setChecked(_viewport->isLodAutoSwitchingEnabled()); + connect(auto_switch_action, &QAction::triggered, this, &W3DViewMainWindow::toggleLodAutoSwitch); + + menu.addSeparator(); + auto *make_aggregate_action = menu.addAction("Make Aggregate..."); + connect(make_aggregate_action, &QAction::triggered, this, &W3DViewMainWindow::makeAggregate); + + menu.exec(_treeView->viewport()->mapToGlobal(pos)); + return; + } + + if (matches_group(group_label, "Hierarchy")) { + QMenu menu(this); + auto *generate_lod_action = menu.addAction("Generate LOD..."); + connect(generate_lod_action, &QAction::triggered, this, &W3DViewMainWindow::generateLod); + auto *make_aggregate_action = menu.addAction("Make Aggregate..."); + connect(make_aggregate_action, &QAction::triggered, this, &W3DViewMainWindow::makeAggregate); + menu.exec(_treeView->viewport()->mapToGlobal(pos)); + return; + } + + if (matches_group(group_label, "Aggregate")) { + QMenu menu(this); + auto *rename_action = menu.addAction("Rename Aggregate..."); + connect(rename_action, &QAction::triggered, this, &W3DViewMainWindow::renameAggregate); + menu.addSeparator(); + auto *bone_action = menu.addAction("Bone Management..."); + connect(bone_action, &QAction::triggered, this, &W3DViewMainWindow::openBoneManagement); + auto *auto_assign_action = menu.addAction("Auto Assign Bone Models"); + connect(auto_assign_action, &QAction::triggered, this, &W3DViewMainWindow::autoAssignBoneModels); + menu.addSeparator(); + auto *bind_action = menu.addAction("Bind Subobject LOD"); + bind_action->setCheckable(true); + bind_action->setChecked(_viewport->isSubobjectLodBound()); + connect(bind_action, &QAction::triggered, this, &W3DViewMainWindow::bindSubobjectLod); + auto *generate_lod_action = menu.addAction("Generate LOD..."); + connect(generate_lod_action, &QAction::triggered, this, &W3DViewMainWindow::generateLod); + menu.exec(_treeView->viewport()->mapToGlobal(pos)); + return; + } +} + +void W3DViewMainWindow::startAnimation() +{ + if (_viewport) { + _viewport->setAnimationState(W3DViewport::AnimationState::Playing); + refreshAnimationMenu(); + playAnimationSound(); + statusBar()->showMessage("Animation playing."); + } +} + +void W3DViewMainWindow::pauseAnimation() +{ + if (!_viewport) { + return; + } + + const auto state = _viewport->animationState(); + if (state == W3DViewport::AnimationState::Playing) { + _viewport->setAnimationState(W3DViewport::AnimationState::Paused); + statusBar()->showMessage("Animation paused."); + } else if (state == W3DViewport::AnimationState::Paused) { + _viewport->setAnimationState(W3DViewport::AnimationState::Playing); + playAnimationSound(); + statusBar()->showMessage("Animation resumed."); + } + refreshAnimationMenu(); +} + +void W3DViewMainWindow::stopAnimation() +{ + if (_viewport) { + _viewport->setAnimationState(W3DViewport::AnimationState::Stopped); + refreshAnimationMenu(); + statusBar()->showMessage("Animation stopped."); + } +} + +void W3DViewMainWindow::stepAnimationForward() +{ + if (!_viewport) { + return; + } + + if (!_viewport->stepAnimation(1)) { + statusBar()->showMessage("No animation to step."); + } +} + +void W3DViewMainWindow::stepAnimationBackward() +{ + if (!_viewport) { + return; + } + + if (!_viewport->stepAnimation(-1)) { + statusBar()->showMessage("No animation to step."); + } +} + +void W3DViewMainWindow::openAnimationSettings() +{ + if (!_viewport) { + return; + } + + AnimationSettingsDialog dialog(*_viewport, this); + dialog.exec(); +} + +void W3DViewMainWindow::openAdvancedAnimation() +{ + if (!_viewport || !_treeView) { + return; + } + + QString name; + if (!GetSelectedRenderObjectName(_treeView, name)) { + QMessageBox::information(this, + "Advanced Animation", + "Select a render object or animation before opening advanced controls."); + return; + } + + AdvancedAnimationDialog dialog(_viewport, name, this); + if (dialog.exec() == QDialog::Accepted) { + playAnimationSound(); + statusBar()->showMessage("Applied advanced animation mix."); + } +} + +void W3DViewMainWindow::generateLod() +{ + QString name; + int class_id = 0; + if (!GetSelectedRenderObject(_treeView, name, class_id)) { + QMessageBox::information(this, "Generate LOD", "Select a hierarchy to generate an LOD."); + return; + } + Q_UNUSED(class_id); + + LodNamingType type = LodNamingType::Commando; + if (!IsLodNameValid(name, type)) { + QMessageBox::information(this, + "Generate LOD", + "Selected hierarchy name does not match LOD naming conventions."); + return; + } + + QString base_name = name; + if (type == LodNamingType::Commando) { + base_name.chop(2); + } else { + base_name.chop(1); + } + + HLodPrototypeClass *prototype = GenerateLodPrototype(base_name, type); + if (!prototype) { + QMessageBox::warning(this, "Generate LOD", "Failed to generate LOD."); + return; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + delete prototype; + QMessageBox::warning(this, "Generate LOD", "WW3D asset manager is not available."); + return; + } + + asset_manager->Add_Prototype(prototype); + rebuildAssetTree(); + statusBar()->showMessage(QString("Generated LOD: %1").arg(prototype->Get_Name())); +} + +void W3DViewMainWindow::makeAggregate() +{ + QString name; + int class_id = 0; + if (!GetSelectedRenderObject(_treeView, name, class_id)) { + QMessageBox::information(this, "Make Aggregate", "Select a hierarchy to make an aggregate."); + return; + } + Q_UNUSED(class_id); + + AggregateNameDialog dialog("Make Aggregate", QString(), this); + if (dialog.exec() != QDialog::Accepted) { + return; + } + + const QString aggregate_name = dialog.name(); + if (aggregate_name.isEmpty()) { + QMessageBox::information(this, "Make Aggregate", "Aggregate name is required."); + return; + } + if (aggregate_name.compare(name, Qt::CaseInsensitive) == 0) { + QMessageBox::warning(this, + "Make Aggregate", + "The aggregate name must differ from its base-model name; using the " + "same name would create a self-recursive prototype."); + return; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + QMessageBox::warning(this, "Make Aggregate", "WW3D asset manager is not available."); + return; + } + + const QByteArray name_bytes = name.toLatin1(); + RenderObjClass *render_obj = asset_manager->Create_Render_Obj(name_bytes.constData()); + if (!render_obj) { + QMessageBox::warning(this, "Make Aggregate", "Failed to load hierarchy."); + return; + } + + auto *definition = new AggregateDefClass(*render_obj); + const QByteArray aggregate_bytes = aggregate_name.toLatin1(); + definition->Set_Name(aggregate_bytes.constData()); + auto *prototype = new AggregatePrototypeClass(definition); + + asset_manager->Remove_Prototype(definition->Get_Name()); + asset_manager->Add_Prototype(prototype); + + render_obj->Release_Ref(); + rebuildAssetTree(); + statusBar()->showMessage(QString("Created aggregate: %1").arg(aggregate_name)); +} + +void W3DViewMainWindow::renameAggregate() +{ + QString name; + int class_id = 0; + if (!GetSelectedRenderObject(_treeView, name, class_id)) { + QMessageBox::information(this, "Rename Aggregate", "Select an aggregate to rename."); + return; + } + Q_UNUSED(class_id); + + const RenderObjInfo info = InspectRenderObj(name.toLatin1().constData()); + if (!info.isAggregate) { + QMessageBox::information(this, "Rename Aggregate", "Selected object is not an aggregate."); + return; + } + + AggregateNameDialog dialog("Rename Aggregate", name, this); + if (dialog.exec() != QDialog::Accepted) { + return; + } + + const QString new_name = dialog.name(); + if (new_name.isEmpty()) { + QMessageBox::information(this, "Rename Aggregate", "Aggregate name is required."); + return; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + QMessageBox::warning(this, "Rename Aggregate", "WW3D asset manager is not available."); + return; + } + + const QByteArray old_name_bytes = name.toLatin1(); + auto *aggregate_prototype = dynamic_cast( + asset_manager->Find_Prototype(old_name_bytes.constData())); + AggregateDefClass *aggregate_definition = + aggregate_prototype ? aggregate_prototype->Get_Definition() : nullptr; + const char *base_model_name = + aggregate_definition ? aggregate_definition->Get_Base_Model_Name() : nullptr; + if (base_model_name && + new_name.compare(QString::fromLatin1(base_model_name), Qt::CaseInsensitive) == 0) { + QMessageBox::warning(this, + "Rename Aggregate", + "The aggregate name must differ from its base-model name; using the " + "same name would create a self-recursive prototype."); + return; + } + + if (!RenameAggregatePrototype(name.toLatin1().constData(), new_name.toLatin1().constData())) { + QMessageBox::warning(this, "Rename Aggregate", "Failed to rename aggregate."); + return; + } + + if (_viewport) { + const QByteArray new_bytes = new_name.toLatin1(); + RenderObjClass *render_obj = asset_manager->Create_Render_Obj(new_bytes.constData()); + if (render_obj) { + _viewport->clearAnimation(); + _viewport->setRenderObject(render_obj); + render_obj->Release_Ref(); + } + } + + rebuildAssetTree(); + statusBar()->showMessage(QString("Renamed aggregate: %1").arg(new_name)); +} + +void W3DViewMainWindow::openBoneManagement() +{ + QString name; + int class_id = 0; + if (!GetSelectedRenderObject(_treeView, name, class_id)) { + QMessageBox::information(this, "Bone Management", "Select an aggregate to edit bones."); + return; + } + Q_UNUSED(class_id); + + const RenderObjInfo info = InspectRenderObj(name.toLatin1().constData()); + if (!info.isAggregate) { + QMessageBox::information(this, "Bone Management", "Selected object is not an aggregate."); + return; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + QMessageBox::warning(this, "Bone Management", "WW3D asset manager is not available."); + return; + } + + const QByteArray name_bytes = name.toLatin1(); + RenderObjClass *render_obj = asset_manager->Create_Render_Obj(name_bytes.constData()); + if (!render_obj) { + QMessageBox::warning(this, "Bone Management", "Failed to load aggregate."); + return; + } + + if (_viewport) { + _viewport->clearAnimation(); + _viewport->setRenderObject(render_obj); + } + + BoneManagementDialog dialog(render_obj, _viewport, this); + const int result = dialog.exec(); + render_obj->Release_Ref(); + + if (result == QDialog::Accepted) { + statusBar()->showMessage("Updated aggregate bones."); + } +} + +void W3DViewMainWindow::autoAssignBoneModels() +{ + QString name; + int class_id = 0; + if (!GetSelectedRenderObject(_treeView, name, class_id)) { + QMessageBox::information(this, "Auto Assign Bones", "Select an aggregate to assign bones."); + return; + } + Q_UNUSED(class_id); + + const RenderObjInfo info = InspectRenderObj(name.toLatin1().constData()); + if (!info.isAggregate) { + QMessageBox::information(this, "Auto Assign Bones", "Selected object is not an aggregate."); + return; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + QMessageBox::warning(this, "Auto Assign Bones", "WW3D asset manager is not available."); + return; + } + + const QByteArray name_bytes = name.toLatin1(); + RenderObjClass *render_obj = asset_manager->Create_Render_Obj(name_bytes.constData()); + if (!render_obj) { + QMessageBox::warning(this, "Auto Assign Bones", "Failed to load aggregate."); + return; + } + + bool updated = false; + const int bone_count = render_obj->Get_Num_Bones(); + for (int index = 0; index < bone_count; ++index) { + const char *bone_name = render_obj->Get_Bone_Name(index); + if (!bone_name || !bone_name[0]) { + continue; + } + + if (!asset_manager->Render_Obj_Exists(bone_name)) { + continue; + } + + RenderObjClass *bone_obj = asset_manager->Create_Render_Obj(bone_name); + if (!bone_obj) { + continue; + } + + render_obj->Add_Sub_Object_To_Bone(bone_obj, index); + bone_obj->Release_Ref(); + updated = true; + } + + if (updated) { + UpdateAggregatePrototype(*render_obj); + } + + if (_viewport) { + _viewport->clearAnimation(); + _viewport->setRenderObject(render_obj); + } + + render_obj->Release_Ref(); + statusBar()->showMessage(updated ? "Auto assigned bone models." : "No matching bone models found."); +} + +void W3DViewMainWindow::bindSubobjectLod() +{ + if (!_viewport) { + return; + } + + const bool enabled = _viewport->toggleSubobjectLod(); + statusBar()->showMessage(enabled ? "Subobject LOD binding enabled." : "Subobject LOD binding disabled."); +} + +void W3DViewMainWindow::createEmitter() +{ + ParticleEmitterDefClass definition = CreateDefaultEmitterDefinition(); + EmitterEditDialog dialog(definition, this); + dialog.setApplyHandler( + [this](const ParticleEmitterDefClass &updated, const QString ®isteredName) { + return commitEmitterDefinition(updated, registeredName, false, false); + }, + QString(), + true); + dialog.exec(); +} + +void W3DViewMainWindow::scaleEmitter() +{ + QString name; + int class_id = 0; + if (!GetSelectedRenderObject(_treeView, name, class_id)) { + QMessageBox::information(this, "Scale Emitter", "Select an emitter to scale."); + return; + } + if (class_id != RenderObjClass::CLASSID_PARTICLEEMITTER) { + QMessageBox::information(this, "Scale Emitter", "Selected object is not an emitter."); + return; + } + + ScaleDialog dialog(1.0, "Enter the scaling factor you want to apply to the current particle emitter.", this); + if (dialog.exec() != QDialog::Accepted) { + return; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + QMessageBox::warning(this, "Scale Emitter", "WW3D asset manager is not available."); + return; + } + + const QByteArray name_bytes = name.toLatin1(); + RenderObjClass *render_obj = asset_manager->Create_Render_Obj(name_bytes.constData()); + if (!render_obj) { + QMessageBox::warning(this, "Scale Emitter", "Failed to load emitter."); + return; + } + + if (render_obj->Class_ID() != RenderObjClass::CLASSID_PARTICLEEMITTER) { + render_obj->Release_Ref(); + QMessageBox::warning(this, "Scale Emitter", "Selected object is not an emitter."); + return; + } + + auto *emitter = static_cast(render_obj); + emitter->Scale(static_cast(dialog.scale())); + + ParticleEmitterDefClass *definition = emitter->Build_Definition(); + if (!definition) { + emitter->Release_Ref(); + QMessageBox::warning(this, "Scale Emitter", "Failed to update emitter definition."); + return; + } + + UpdateEmitterPrototype(*definition, name); + delete definition; + + if (_viewport) { + _viewport->clearAnimation(); + _viewport->setRenderObject(emitter); + } + emitter->Release_Ref(); + statusBar()->showMessage("Scaled emitter."); +} + +void W3DViewMainWindow::editEmitter() +{ + QString name; + int class_id = 0; + if (!GetSelectedRenderObject(_treeView, name, class_id)) { + QMessageBox::information(this, "Edit Emitter", "Select an emitter to edit."); + return; + } + if (class_id != RenderObjClass::CLASSID_PARTICLEEMITTER) { + QMessageBox::information(this, "Edit Emitter", "Selected object is not an emitter."); + return; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + QMessageBox::warning(this, "Edit Emitter", "WW3D asset manager is not available."); + return; + } + + const QByteArray name_bytes = name.toLatin1(); + RenderObjClass *render_obj = asset_manager->Create_Render_Obj(name_bytes.constData()); + if (!render_obj) { + QMessageBox::warning(this, "Edit Emitter", "Failed to load emitter."); + return; + } + + if (render_obj->Class_ID() != RenderObjClass::CLASSID_PARTICLEEMITTER) { + render_obj->Release_Ref(); + QMessageBox::warning(this, "Edit Emitter", "Selected object is not an emitter."); + return; + } + + auto *emitter = static_cast(render_obj); + ParticleEmitterDefClass *definition = emitter->Build_Definition(); + emitter->Release_Ref(); + if (!definition) { + QMessageBox::warning(this, "Edit Emitter", "Failed to load emitter definition."); + return; + } + + EmitterEditDialog dialog(*definition, this); + delete definition; + dialog.setApplyHandler( + [this](const ParticleEmitterDefClass &updated, const QString ®isteredName) { + return commitEmitterDefinition(updated, registeredName, false, false); + }, + dialog.originalName()); + dialog.exec(); +} + +void W3DViewMainWindow::createSphere() +{ + if (!_viewport) { + return; + } + + SphereEditDialog dialog(nullptr, this); + SphereRenderObjClass *preview = dialog.sphere(); + if (!preview) { + QMessageBox::warning(this, "Create Sphere", "Failed to create sphere."); + return; + } + _viewport->clearAnimation(); + _viewport->setRenderObject(preview); + preview->Release_Ref(); + + dialog.setApplyHandler( + [this](SphereRenderObjClass &sphere, const QString ®istered_name) { + QString error_message; + if (!UpdateSpherePrototype(sphere, registered_name, &error_message)) { + QMessageBox::warning(this, + "Apply Sphere", + error_message.isEmpty() + ? "Failed to register sphere prototype." + : error_message); + return false; + } + + const QString name = QString::fromLatin1(sphere.Get_Name()); + const bool created = registered_name.isEmpty(); + rebuildAssetTree(); + if (_treeModel && _treeView) { + const QModelIndex index = FindRenderObjectIndex( + _treeModel, name, RenderObjClass::CLASSID_SPHERE); + if (index.isValid()) { + ExpandParentChain(_treeView, index.parent()); + _treeView->setCurrentIndex(index); + _treeView->scrollTo(index); + } + } + if (_viewport) { + _viewport->clearAnimation(); + _viewport->setRenderObject(&sphere); + } + statusBar()->showMessage(created ? QString("Created sphere: %1").arg(name) + : QString("Updated sphere: %1").arg(name)); + return true; + }, + QString(), + true); + + if (dialog.exec() != QDialog::Accepted) { + onCurrentChanged(_treeView ? _treeView->currentIndex() : QModelIndex(), QModelIndex()); + } +} + +void W3DViewMainWindow::createRing() +{ + if (!_viewport) { + return; + } + + RingEditDialog dialog(nullptr, this); + RingRenderObjClass *preview = dialog.ring(); + if (!preview) { + QMessageBox::warning(this, "Create Ring", "Failed to create ring."); + return; + } + _viewport->clearAnimation(); + _viewport->setRenderObject(preview); + preview->Release_Ref(); + + dialog.setApplyHandler( + [this](RingRenderObjClass &ring, const QString ®istered_name) { + QString error_message; + if (!UpdateRingPrototype(ring, registered_name, &error_message)) { + QMessageBox::warning(this, + "Apply Ring", + error_message.isEmpty() + ? "Failed to register ring prototype." + : error_message); + return false; + } + + const QString name = QString::fromLatin1(ring.Get_Name()); + const bool created = registered_name.isEmpty(); + rebuildAssetTree(); + if (_treeModel && _treeView) { + const QModelIndex index = FindRenderObjectIndex( + _treeModel, name, RenderObjClass::CLASSID_RING); + if (index.isValid()) { + ExpandParentChain(_treeView, index.parent()); + _treeView->setCurrentIndex(index); + _treeView->scrollTo(index); + } + } + if (_viewport) { + _viewport->clearAnimation(); + _viewport->setRenderObject(&ring); + } + statusBar()->showMessage(created ? QString("Created ring: %1").arg(name) + : QString("Updated ring: %1").arg(name)); + return true; + }, + QString(), + true); + + if (dialog.exec() != QDialog::Accepted) { + onCurrentChanged(_treeView ? _treeView->currentIndex() : QModelIndex(), QModelIndex()); + } +} + +void W3DViewMainWindow::editPrimitive() +{ + QString name; + int class_id = 0; + if (!GetSelectedRenderObject(_treeView, name, class_id)) { + QMessageBox::information(this, "Edit Primitive", "Select a primitive to edit."); + return; + } + if (class_id != RenderObjClass::CLASSID_SPHERE && + class_id != RenderObjClass::CLASSID_RING) { + QMessageBox::information(this, "Edit Primitive", "Selected object is not a primitive."); + return; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + QMessageBox::warning(this, "Edit Primitive", "WW3D asset manager is not available."); + return; + } + + const QByteArray name_bytes = name.toLatin1(); + RenderObjClass *render_obj = asset_manager->Create_Render_Obj(name_bytes.constData()); + if (!render_obj) { + QMessageBox::warning(this, "Edit Primitive", "Failed to load primitive."); + return; + } + + if (class_id == RenderObjClass::CLASSID_SPHERE) { + if (render_obj->Class_ID() != RenderObjClass::CLASSID_SPHERE) { + render_obj->Release_Ref(); + QMessageBox::warning(this, "Edit Primitive", "Selected object is not a sphere."); + return; + } + + auto *sphere = static_cast(render_obj); + SphereEditDialog dialog(sphere, this); + sphere->Release_Ref(); + SphereRenderObjClass *preview = dialog.sphere(); + if (!preview) { + QMessageBox::warning(this, "Edit Primitive", "Failed to update sphere."); + return; + } + if (_viewport) { + _viewport->clearAnimation(); + _viewport->setRenderObject(preview); + } + preview->Release_Ref(); + + dialog.setApplyHandler( + [this](SphereRenderObjClass &updated, const QString ®istered_name) { + QString error_message; + if (!UpdateSpherePrototype(updated, registered_name, &error_message)) { + QMessageBox::warning(this, + "Apply Sphere", + error_message.isEmpty() + ? "Failed to register sphere prototype." + : error_message); + return false; + } + + const QString updated_name = QString::fromLatin1(updated.Get_Name()); + rebuildAssetTree(); + if (_treeModel && _treeView) { + const QModelIndex index = FindRenderObjectIndex( + _treeModel, updated_name, RenderObjClass::CLASSID_SPHERE); + if (index.isValid()) { + ExpandParentChain(_treeView, index.parent()); + _treeView->setCurrentIndex(index); + _treeView->scrollTo(index); + } + } + if (_viewport) { + _viewport->clearAnimation(); + _viewport->setRenderObject(&updated); + } + statusBar()->showMessage(QString("Updated sphere: %1").arg(updated_name)); + return true; + }, + dialog.oldName()); + + if (dialog.exec() != QDialog::Accepted) { + onCurrentChanged(_treeView ? _treeView->currentIndex() : QModelIndex(), QModelIndex()); + } + return; + } + + if (render_obj->Class_ID() != RenderObjClass::CLASSID_RING) { + render_obj->Release_Ref(); + QMessageBox::warning(this, "Edit Primitive", "Selected object is not a ring."); + return; + } + + auto *ring = static_cast(render_obj); + RingEditDialog dialog(ring, this); + ring->Release_Ref(); + RingRenderObjClass *preview = dialog.ring(); + if (!preview) { + QMessageBox::warning(this, "Edit Primitive", "Failed to update ring."); + return; + } + if (_viewport) { + _viewport->clearAnimation(); + _viewport->setRenderObject(preview); + } + preview->Release_Ref(); + + dialog.setApplyHandler( + [this](RingRenderObjClass &updated, const QString ®istered_name) { + QString error_message; + if (!UpdateRingPrototype(updated, registered_name, &error_message)) { + QMessageBox::warning(this, + "Apply Ring", + error_message.isEmpty() + ? "Failed to register ring prototype." + : error_message); + return false; + } + + const QString updated_name = QString::fromLatin1(updated.Get_Name()); + rebuildAssetTree(); + if (_treeModel && _treeView) { + const QModelIndex index = FindRenderObjectIndex( + _treeModel, updated_name, RenderObjClass::CLASSID_RING); + if (index.isValid()) { + ExpandParentChain(_treeView, index.parent()); + _treeView->setCurrentIndex(index); + _treeView->scrollTo(index); + } + } + if (_viewport) { + _viewport->clearAnimation(); + _viewport->setRenderObject(&updated); + } + statusBar()->showMessage(QString("Updated ring: %1").arg(updated_name)); + return true; + }, + dialog.oldName()); + + if (dialog.exec() != QDialog::Accepted) { + onCurrentChanged(_treeView ? _treeView->currentIndex() : QModelIndex(), QModelIndex()); + } +} + +void W3DViewMainWindow::createSoundObject() +{ + SoundEditDialog dialog(nullptr, this); + if (dialog.exec() != QDialog::Accepted) { + return; + } + + SoundRenderObjClass *sound_obj = dialog.sound(); + if (!sound_obj) { + QMessageBox::warning(this, "Create Sound Object", "Failed to create sound object."); + return; + } + + QString error_message; + if (!UpdateSoundPrototype(*sound_obj, dialog.oldName(), &error_message)) { + sound_obj->Release_Ref(); + QMessageBox::warning(this, + "Create Sound Object", + error_message.isEmpty() ? "Failed to register sound object." + : error_message); + return; + } + + const char *created_name_ptr = sound_obj->Get_Name(); + const QString created_name = created_name_ptr ? QString::fromLatin1(created_name_ptr) : QString(); + rebuildAssetTree(); + if (_treeModel && _treeView) { + const QModelIndex index = FindRenderObjectIndex( + _treeModel, created_name, RenderObjClass::CLASSID_SOUND); + if (index.isValid()) { + ExpandParentChain(_treeView, index.parent()); + _treeView->setCurrentIndex(index); + _treeView->scrollTo(index); + } + } + if (_viewport) { + _viewport->clearAnimation(); + _viewport->setRenderObject(sound_obj); + } + statusBar()->showMessage(QString("Created sound object: %1").arg(created_name)); + sound_obj->Release_Ref(); +} + +void W3DViewMainWindow::editSoundObject() +{ + QString name; + int class_id = 0; + if (!GetSelectedRenderObject(_treeView, name, class_id)) { + QMessageBox::information(this, "Edit Sound Object", "Select a sound object to edit."); + return; + } + if (class_id != RenderObjClass::CLASSID_SOUND) { + QMessageBox::information(this, "Edit Sound Object", "Selected object is not a sound object."); + return; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + QMessageBox::warning(this, "Edit Sound Object", "WW3D asset manager is not available."); + return; + } + + const QByteArray name_bytes = name.toLatin1(); + RenderObjClass *render_obj = asset_manager->Create_Render_Obj(name_bytes.constData()); + if (!render_obj) { + QMessageBox::warning(this, "Edit Sound Object", "Failed to load sound object."); + return; + } + + if (render_obj->Class_ID() != RenderObjClass::CLASSID_SOUND) { + render_obj->Release_Ref(); + QMessageBox::warning(this, "Edit Sound Object", "Selected object is not a sound object."); + return; + } + + auto *sound_obj = static_cast(render_obj); + SoundEditDialog dialog(sound_obj, this); + const int result = dialog.exec(); + sound_obj->Release_Ref(); + + if (result != QDialog::Accepted) { + return; + } + + SoundRenderObjClass *updated = dialog.sound(); + if (!updated) { + QMessageBox::warning(this, "Edit Sound Object", "Failed to update sound object."); + return; + } + + QString error_message; + if (!UpdateSoundPrototype(*updated, dialog.oldName(), &error_message)) { + updated->Release_Ref(); + QMessageBox::warning(this, + "Edit Sound Object", + error_message.isEmpty() ? "Failed to register sound object." + : error_message); + return; + } + + const char *updated_name_ptr = updated->Get_Name(); + const QString updated_name = updated_name_ptr ? QString::fromLatin1(updated_name_ptr) : QString(); + rebuildAssetTree(); + if (_treeModel && _treeView) { + const QModelIndex index = FindRenderObjectIndex( + _treeModel, updated_name, RenderObjClass::CLASSID_SOUND); + if (index.isValid()) { + ExpandParentChain(_treeView, index.parent()); + _treeView->setCurrentIndex(index); + _treeView->scrollTo(index); + } + } + if (_viewport) { + _viewport->clearAnimation(); + _viewport->setRenderObject(updated); + } + statusBar()->showMessage(QString("Updated sound object: %1").arg(updated_name)); + updated->Release_Ref(); +} + +void W3DViewMainWindow::openAnimatedSoundOptions() +{ + QSettings settings; + const QString definition_path = settings.value("Config/SoundDefLibPath").toString(); + const QString ini_path = settings.value("Config/AnimSoundINIPath").toString(); + const QString data_path = settings.value("Config/AnimSoundDataPath").toString(); + + AnimatedSoundOptionsDialog dialog(definition_path, ini_path, data_path, this); + if (dialog.exec() != QDialog::Accepted) { + return; + } + + const auto normalize = [](const QString &value) -> QString { + const QString trimmed = value.trimmed(); + if (trimmed.isEmpty()) { + return QString(); + } + return QDir::toNativeSeparators(QDir::cleanPath(trimmed)); + }; + + const QString new_definition = normalize(dialog.definitionLibraryPath()); + const QString new_ini = normalize(dialog.iniPath()); + const QString new_data = normalize(dialog.dataPath()); + + settings.setValue("Config/SoundDefLibPath", new_definition); + settings.setValue("Config/AnimSoundINIPath", new_ini); + settings.setValue("Config/AnimSoundDataPath", new_data); + + AnimatedSoundOptionsDialog::LoadAnimatedSoundSettings(); + statusBar()->showMessage("Animated sound options updated."); +} + +void W3DViewMainWindow::importFacialAnims() +{ + if (!_treeView) { + return; + } + + const QString hierarchy = GetSelectedHierarchyName(_treeView); + if (hierarchy.isEmpty()) { + QMessageBox::information(this, "Import Facial Anims", "Select a hierarchy before importing."); + return; + } + + const QString start_dir = _lastOpenedPath.isEmpty() ? QDir::currentPath() : _lastOpenedPath; + const QStringList files = QFileDialog::getOpenFileNames( + this, + "Import Facial Anims", + start_dir, + "Animation Description (*.txt)"); + if (files.isEmpty()) { + return; + } + + QGuiApplication::setOverrideCursor(Qt::WaitCursor); + int imported = 0; + for (const QString &path : files) { + if (ImportFacialAnimation(hierarchy, path)) { + ++imported; + } + } + QGuiApplication::restoreOverrideCursor(); + + if (imported > 0) { + rebuildAssetTree(); + statusBar()->showMessage(QString("Imported %1 facial animation(s).").arg(imported)); + } else { + QMessageBox::warning(this, "Import Facial Anims", "No facial animations were imported."); + } +} + +bool W3DViewMainWindow::confirmExportTarget(const QString &path) +{ + const QFileInfo target_info(path); + if (!target_info.exists()) { + return true; + } + + const auto normalized_path = [](const QString &candidate) { + const QFileInfo info(candidate); + const QString canonical = info.canonicalFilePath(); + return QDir::cleanPath(canonical.isEmpty() ? info.absoluteFilePath() : canonical); + }; + + const QString target = normalized_path(path); + const bool replaces_loaded_file = std::any_of( + _loadedFiles.cbegin(), _loadedFiles.cend(), [&](const QString &loaded_file) { + return normalized_path(loaded_file).compare(target, Qt::CaseInsensitive) == 0; + }); + if (replaces_loaded_file) { + const QMessageBox::StandardButton answer = QMessageBox::warning( + this, + "Export W3D", + "The selected target is one of the currently loaded W3D files.\n\n" + "An export contains only the selected definition and will replace every " + "other chunk in that file.\n\nReplace the loaded file anyway?", + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No); + return answer == QMessageBox::Yes; + } + + const QMessageBox::StandardButton answer = QMessageBox::warning( + this, + "Export W3D", + QString("The export target already exists:\n\n%1\n\nReplace it?") + .arg(QDir::toNativeSeparators(target_info.absoluteFilePath())), + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No); + return answer == QMessageBox::Yes; +} + +void W3DViewMainWindow::exportAggregate() +{ + QString name; + int class_id = 0; + if (!GetSelectedRenderObject(_treeView, name, class_id)) { + QMessageBox::information(this, "Export Aggregate", "Select an aggregate to export."); + return; + } + + const RenderObjInfo info = InspectRenderObj(name.toLatin1().constData()); + if (!info.isAggregate) { + QMessageBox::information(this, "Export Aggregate", "Selected object is not an aggregate."); + return; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + QMessageBox::warning(this, "Export Aggregate", "WW3D asset manager is not available."); + return; + } + + const QByteArray name_bytes = name.toLatin1(); + auto *proto = dynamic_cast( + asset_manager->Find_Prototype(name_bytes.constData())); + if (!proto) { + QMessageBox::warning(this, + "Export Aggregate", + "The selected prototype is not an aggregate."); + return; + } + + AggregateDefClass *definition = proto->Get_Definition(); + if (!definition) { + QMessageBox::warning(this, "Export Aggregate", "Aggregate definition not available."); + return; + } + + const QString path = + SelectExportPath(this, "Export Aggregate", name + ".w3d", _lastOpenedPath); + if (path.isEmpty()) { + return; + } + if (!confirmExportTarget(path)) { + return; + } + + QString error_message; + const bool ok = W3DExportUtils::SaveChunkFileAtomically( + path, + W3D_CHUNK_AGGREGATE, + [definition](ChunkSaveClass &save_chunk) { + return definition->Save_W3D(save_chunk) == WW3D_ERROR_OK; + }, + &error_message); + if (!ok) { + QMessageBox::warning(this, + "Export Aggregate", + QString("Failed to export aggregate.\n\n%1").arg(error_message)); + return; + } + + statusBar()->showMessage(QString("Exported aggregate: %1").arg(QFileInfo(path).fileName())); +} + +void W3DViewMainWindow::exportEmitter() +{ + QString name; + int class_id = 0; + if (!GetSelectedRenderObject(_treeView, name, class_id)) { + QMessageBox::information(this, "Export Emitter", "Select an emitter to export."); + return; + } + + if (class_id != RenderObjClass::CLASSID_PARTICLEEMITTER) { + QMessageBox::information(this, "Export Emitter", "Selected object is not an emitter."); + return; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + QMessageBox::warning(this, "Export Emitter", "WW3D asset manager is not available."); + return; + } + + const QByteArray name_bytes = name.toLatin1(); + auto *proto = dynamic_cast( + asset_manager->Find_Prototype(name_bytes.constData())); + if (!proto) { + QMessageBox::warning(this, + "Export Emitter", + "The selected prototype is not an emitter."); + return; + } + + ParticleEmitterDefClass *definition = proto->Get_Definition(); + if (!definition) { + QMessageBox::warning(this, "Export Emitter", "Emitter definition not available."); + return; + } + + const QString path = + SelectExportPath(this, "Export Emitter", name + ".w3d", _lastOpenedPath); + if (path.isEmpty()) { + return; + } + if (!confirmExportTarget(path)) { + return; + } + + QString error_message; + const bool ok = W3DExportUtils::SaveChunkFileAtomically( + path, + W3D_CHUNK_EMITTER, + [definition](ChunkSaveClass &save_chunk) { + return definition->Save_W3D(save_chunk) == WW3D_ERROR_OK; + }, + &error_message); + if (!ok) { + QMessageBox::warning(this, + "Export Emitter", + QString("Failed to export emitter.\n\n%1").arg(error_message)); + return; + } + + statusBar()->showMessage(QString("Exported emitter: %1").arg(QFileInfo(path).fileName())); +} + +void W3DViewMainWindow::exportLod() +{ + QString name; + int class_id = 0; + if (!GetSelectedRenderObject(_treeView, name, class_id)) { + QMessageBox::information(this, "Export LOD", "Select an LOD to export."); + return; + } + + if (class_id != RenderObjClass::CLASSID_HLOD) { + QMessageBox::information(this, "Export LOD", "Selected object is not an LOD."); + return; + } + + const RenderObjInfo info = InspectRenderObj(name.toLatin1().constData()); + if (!info.isRealLod || info.isAggregate) { + QMessageBox::information(this, + "Export LOD", + "Selected object is not a multilevel LOD."); + return; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + QMessageBox::warning(this, "Export LOD", "WW3D asset manager is not available."); + return; + } + + const QByteArray name_bytes = name.toLatin1(); + auto *proto = dynamic_cast( + asset_manager->Find_Prototype(name_bytes.constData())); + if (!proto) { + QMessageBox::warning(this, + "Export LOD", + "The selected prototype is not an HLOD."); + return; + } + + HLodDefClass *definition = proto->Get_Definition(); + if (!definition) { + QMessageBox::warning(this, "Export LOD", "LOD definition not available."); + return; + } + + const QString path = + SelectExportPath(this, "Export LOD", name + ".w3d", _lastOpenedPath); + if (path.isEmpty()) { + return; + } + if (!confirmExportTarget(path)) { + return; + } + + QString error_message; + const bool ok = W3DExportUtils::SaveChunkFileAtomically( + path, + W3D_CHUNK_HLOD, + [definition](ChunkSaveClass &save_chunk) { + return definition->Save(save_chunk) == WW3D_ERROR_OK; + }, + &error_message); + if (!ok) { + QMessageBox::warning(this, + "Export LOD", + QString("Failed to export LOD.\n\n%1").arg(error_message)); + return; + } + + statusBar()->showMessage(QString("Exported LOD: %1").arg(QFileInfo(path).fileName())); +} + +void W3DViewMainWindow::exportPrimitive() +{ + QString name; + int class_id = 0; + if (!GetSelectedRenderObject(_treeView, name, class_id)) { + QMessageBox::information(this, "Export Primitive", "Select a primitive to export."); + return; + } + + if (class_id != RenderObjClass::CLASSID_SPHERE && + class_id != RenderObjClass::CLASSID_RING) { + QMessageBox::information(this, "Export Primitive", "Selected object is not a primitive."); + return; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + QMessageBox::warning(this, "Export Primitive", "WW3D asset manager is not available."); + return; + } + + const QByteArray name_bytes = name.toLatin1(); + SpherePrototypeClass *sphere_proto = nullptr; + RingPrototypeClass *ring_proto = nullptr; + if (class_id == RenderObjClass::CLASSID_SPHERE) { + sphere_proto = dynamic_cast( + asset_manager->Find_Prototype(name_bytes.constData())); + if (!sphere_proto) { + QMessageBox::warning(this, + "Export Primitive", + "The selected prototype is not a sphere."); + return; + } + } else { + ring_proto = dynamic_cast( + asset_manager->Find_Prototype(name_bytes.constData())); + if (!ring_proto) { + QMessageBox::warning(this, + "Export Primitive", + "The selected prototype is not a ring."); + return; + } + } + + const QString title = class_id == RenderObjClass::CLASSID_SPHERE ? "Export Sphere" : "Export Ring"; + const QString path = SelectExportPath(this, title, name + ".w3d", _lastOpenedPath); + if (path.isEmpty()) { + return; + } + if (!confirmExportTarget(path)) { + return; + } + + QString error_message; + bool ok = false; + if (sphere_proto) { + ok = W3DExportUtils::SaveChunkFileAtomically( + path, + W3D_CHUNK_SPHERE, + [sphere_proto](ChunkSaveClass &save_chunk) { + return sphere_proto->Save(save_chunk); + }, + &error_message); + } else { + ok = W3DExportUtils::SaveChunkFileAtomically( + path, + W3D_CHUNK_RING, + [ring_proto](ChunkSaveClass &save_chunk) { + return ring_proto->Save(save_chunk); + }, + &error_message); + } + + if (!ok) { + QMessageBox::warning(this, + "Export Primitive", + QString("Failed to export primitive.\n\n%1").arg(error_message)); + return; + } + + statusBar()->showMessage(QString("Exported primitive: %1").arg(QFileInfo(path).fileName())); +} + +void W3DViewMainWindow::exportSoundObject() +{ + QString name; + int class_id = 0; + if (!GetSelectedRenderObject(_treeView, name, class_id)) { + QMessageBox::information(this, "Export Sound Object", "Select a sound object to export."); + return; + } + + if (class_id != RenderObjClass::CLASSID_SOUND) { + QMessageBox::information(this, "Export Sound Object", "Selected object is not a sound object."); + return; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + QMessageBox::warning(this, "Export Sound Object", "WW3D asset manager is not available."); + return; + } + + const QByteArray name_bytes = name.toLatin1(); + auto *proto = dynamic_cast( + asset_manager->Find_Prototype(name_bytes.constData())); + if (!proto) { + QMessageBox::warning(this, + "Export Sound Object", + "The selected prototype is not a sound object."); + return; + } + + SoundRenderObjDefClass *definition = proto->Peek_Definition(); + if (!definition) { + QMessageBox::warning(this, + "Export Sound Object", + "Sound object definition not available."); + return; + } + + const QString path = + SelectExportPath(this, "Export Sound Object", name + ".w3d", _lastOpenedPath); + if (path.isEmpty()) { + return; + } + if (!confirmExportTarget(path)) { + return; + } + + QString error_message; + const bool ok = W3DExportUtils::SaveChunkFileAtomically( + path, + W3D_CHUNK_SOUNDROBJ, + [definition](ChunkSaveClass &save_chunk) { + return definition->Save_W3D(save_chunk) == WW3D_ERROR_OK; + }, + &error_message); + if (!ok) { + QMessageBox::warning(this, + "Export Sound Object", + QString("Failed to export sound object.\n\n%1").arg(error_message)); + return; + } + + statusBar()->showMessage(QString("Exported sound object: %1").arg(QFileInfo(path).fileName())); +} + +void W3DViewMainWindow::listMissingTextures() +{ + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + QMessageBox::warning(this, "Missing Textures", "WW3D asset manager is not available."); + return; + } + + QStringList missing; + HashTemplateIterator iterator(asset_manager->Texture_Hash()); + for (iterator.First(); !iterator.Is_Done(); iterator.Next()) { + auto *texture = iterator.Peek_Value(); + if (!texture || !texture->Is_Missing_Texture()) { + continue; + } + + const char *name = iterator.Peek_Key(); + if (name && name[0]) { + missing.append(QString::fromLatin1(name)); + } + } + + if (missing.isEmpty()) { + QMessageBox::information(this, "Texture Info", "No Missing Textures!"); + return; + } + + QString message("Warning! The following textures are missing:\n\n"); + message += missing.join('\n'); + QMessageBox::warning(this, "Missing Textures", message); +} + +void W3DViewMainWindow::copyAssets() +{ + if (!_treeView) { + return; + } + + const QModelIndex current = _treeView->currentIndex(); + if (!current.isValid() || + current.data(kRoleType).toInt() != static_cast(AssetNodeType::RenderObject)) { + QMessageBox::information(this, "Copy Asset Files", "Select a render object to copy assets."); + return; + } + + if (_loadedFiles.isEmpty()) { + QMessageBox::warning(this, "Copy Asset Files", "No source directory is available."); + return; + } + + const QString dest_dir = QFileDialog::getExistingDirectory( + this, + "Copy Asset Files", + _lastOpenedPath); + if (dest_dir.isEmpty()) { + return; + } + + const QString name = current.data(kRoleName).toString(); + if (name.isEmpty()) { + return; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + QMessageBox::warning(this, "Copy Asset Files", "WW3D asset manager is not available."); + return; + } + + const QByteArray name_bytes = name.toLatin1(); + RenderObjClass *object = asset_manager->Create_Render_Obj(name_bytes.constData()); + if (!object) { + QMessageBox::information(this, + "Copy Asset Files", + QString("Unable to create render object '%1'.").arg(name)); + return; + } + + DynamicVectorClass dependencies; + object->Build_Dependency_List(dependencies); + object->Release_Ref(); + + if (dependencies.Count() == 0) { + QMessageBox::information(this, "Copy Asset Files", "No dependent assets were found."); + return; + } + + QString source_path; + for (auto it = _loadedFiles.crbegin(); it != _loadedFiles.crend(); ++it) { + const QFileInfo loaded_info(*it); + if (loaded_info.completeBaseName().compare(name, Qt::CaseInsensitive) == 0) { + source_path = loaded_info.absolutePath(); + break; + } + } + if (source_path.isEmpty()) { + QMessageBox::warning(this, + "Copy Asset Files", + QString("The source W3D for '%1' could not be identified.").arg(name)); + return; + } + + const QDir src_root(source_path); + const QDir dest_root(dest_dir); + QStringList failures; + for (int index = 0; index < dependencies.Count(); ++index) { + const char *dep_name = dependencies[index].Peek_Buffer(); + if (!dep_name || !dep_name[0]) { + continue; + } + + const QString filename = QString::fromLatin1(dep_name); + const QString src_path = src_root.filePath(filename); + const QString dest_path = dest_root.filePath(filename); + + if (!QFile::exists(src_path)) { + failures.append(src_path); + continue; + } + + const QFileInfo dest_info(dest_path); + QDir dest_dir = dest_info.dir(); + if (!dest_dir.exists() && !dest_dir.mkpath(".")) { + failures.append(src_path); + continue; + } + + if (QFile::exists(dest_path)) { + QFile::remove(dest_path); + } + + if (!QFile::copy(src_path, dest_path)) { + failures.append(src_path); + } + } + + if (!failures.isEmpty()) { + QString message("Unable to copy the following files:\n\n"); + message += failures.join('\n'); + QMessageBox::warning(this, "Copy Failure", message); + return; + } + + statusBar()->showMessage(QString("Copied assets to %1").arg(dest_dir)); +} + +void W3DViewMainWindow::addToLineup() +{ + if (!_viewport) { + return; + } + + AddToLineupDialog dialog(_viewport, this); + if (dialog.exec() != QDialog::Accepted) { + return; + } + + const QString name = dialog.selectedName(); + if (name.isEmpty()) { + return; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + QMessageBox::warning(this, "Add To Lineup", "WW3D asset manager is not available."); + return; + } + + const QByteArray name_bytes = name.toLatin1(); + RenderObjClass *object = asset_manager->Create_Render_Obj(name_bytes.constData()); + if (!object) { + QMessageBox::information(this, + "Add To Lineup", + QString("Unable to create render object '%1'.").arg(name)); + return; + } + + SetHighestLod(object); + const bool added = _viewport->addToLineup(object); + object->Release_Ref(); + + if (!added) { + QMessageBox::information(this, "Add To Lineup", "Selected object cannot be added to the lineup."); + return; + } + + statusBar()->showMessage(QString("Added to lineup: %1").arg(name)); +} + +void W3DViewMainWindow::showAbout() +{ + QMessageBox::about(this, + "About W3DViewQt", + "W3DViewQt\nQt-based W3D asset viewer."); +} + +void W3DViewMainWindow::recordLodScreenArea() +{ + if (!_viewport) { + return; + } + + if (_viewport->recordLodScreenArea()) { + statusBar()->showMessage("Recorded LOD screen area."); + } else { + statusBar()->showMessage("LOD screen area is not available."); + } +} + +void W3DViewMainWindow::toggleLodIncludeNull(bool enabled) +{ + if (!_viewport) { + return; + } + + if (_viewport->setNullLodIncluded(enabled)) { + statusBar()->showMessage(enabled ? "Included NULL LOD." : "Removed NULL LOD."); + } else { + statusBar()->showMessage("NULL LOD is not available."); + } +} + +void W3DViewMainWindow::selectPrevLod() +{ + if (!_viewport) { + return; + } + + if (!_viewport->adjustLodLevel(-1)) { + statusBar()->showMessage("No previous LOD level."); + } +} + +void W3DViewMainWindow::selectNextLod() +{ + if (!_viewport) { + return; + } + + if (!_viewport->adjustLodLevel(1)) { + statusBar()->showMessage("No next LOD level."); + } +} + +void W3DViewMainWindow::toggleLodAutoSwitch(bool enabled) +{ + if (_viewport) { + _viewport->setLodAutoSwitchingEnabled(enabled); + statusBar()->showMessage(enabled ? "LOD auto switching enabled." : "LOD auto switching disabled."); + } +} + +void W3DViewMainWindow::toggleObjectRotateX(bool enabled) +{ + if (!_viewport) { + return; + } + + int flags = _viewport->objectRotationFlags(); + if (enabled) { + flags |= W3DViewport::RotateX; + flags &= ~W3DViewport::RotateXBack; + } else { + flags &= ~W3DViewport::RotateX; + } + _viewport->setObjectRotationFlags(flags); +} + +void W3DViewMainWindow::toggleObjectRotateY(bool enabled) +{ + if (!_viewport) { + return; + } + + int flags = _viewport->objectRotationFlags(); + if (enabled) { + flags |= W3DViewport::RotateY; + flags &= ~W3DViewport::RotateYBack; + } else { + flags &= ~W3DViewport::RotateY; + } + _viewport->setObjectRotationFlags(flags); +} + +void W3DViewMainWindow::toggleObjectRotateYBack() +{ + if (!_viewport) { + return; + } + + int flags = _viewport->objectRotationFlags(); + flags ^= W3DViewport::RotateYBack; + flags &= ~W3DViewport::RotateY; + _viewport->setObjectRotationFlags(flags); + if (_objectRotateYAction) { + const QSignalBlocker blocker(_objectRotateYAction); + _objectRotateYAction->setChecked((flags & W3DViewport::RotateY) != 0); + } +} + +void W3DViewMainWindow::toggleObjectRotateZ(bool enabled) +{ + if (!_viewport) { + return; + } + + int flags = _viewport->objectRotationFlags(); + if (enabled) { + flags |= W3DViewport::RotateZ; + flags &= ~W3DViewport::RotateZBack; + } else { + flags &= ~W3DViewport::RotateZ; + } + _viewport->setObjectRotationFlags(flags); +} + +void W3DViewMainWindow::toggleObjectRotateZBack() +{ + if (!_viewport) { + return; + } + + int flags = _viewport->objectRotationFlags(); + flags ^= W3DViewport::RotateZBack; + flags &= ~W3DViewport::RotateZ; + _viewport->setObjectRotationFlags(flags); + if (_objectRotateZAction) { + const QSignalBlocker blocker(_objectRotateZAction); + _objectRotateZAction->setChecked((flags & W3DViewport::RotateZ) != 0); + } +} + +void W3DViewMainWindow::resetObject() +{ + if (_viewport) { + _viewport->resetObjectTransform(); + } +} + +void W3DViewMainWindow::toggleAlternateMaterials() +{ + if (_viewport) { + _viewport->toggleAlternateMaterials(); + } +} + +void W3DViewMainWindow::showObjectProperties() +{ + if (!_treeView) { + return; + } + + const QModelIndex current = _treeView->currentIndex(); + if (!current.isValid()) { + QMessageBox::information(this, "Properties", "Select an asset to view properties."); + return; + } + + const int type_value = current.data(kRoleType).toInt(); + if (type_value == static_cast(AssetNodeType::Animation)) { + const QString animation_name = current.data(kRoleName).toString(); + if (animation_name.isEmpty()) { + QMessageBox::information(this, "Properties", "Select an animation to view properties."); + return; + } + + AnimationPropertiesDialog dialog(animation_name, this); + dialog.exec(); + return; + } + + if (type_value != static_cast(AssetNodeType::RenderObject)) { + QMessageBox::information(this, "Properties", "Select a render object to view properties."); + return; + } + + QString name; + int class_id = 0; + if (!GetSelectedRenderObject(_treeView, name, class_id)) { + QMessageBox::information(this, "Properties", "Select a render object to view properties."); + return; + } + + switch (class_id) { + case RenderObjClass::CLASSID_MESH: + { + MeshPropertiesDialog dialog(name, this); + dialog.exec(); + return; + } + case RenderObjClass::CLASSID_COLLECTION: + case RenderObjClass::CLASSID_HMODEL: + case RenderObjClass::CLASSID_DISTLOD: + case RenderObjClass::CLASSID_HLOD: + { + HierarchyPropertiesDialog dialog(name, this); + dialog.exec(); + return; + } + case RenderObjClass::CLASSID_SOUND: + editSoundObject(); + return; + case RenderObjClass::CLASSID_PARTICLEEMITTER: + editEmitter(); + return; + case RenderObjClass::CLASSID_SPHERE: + case RenderObjClass::CLASSID_RING: + editPrimitive(); + return; + default: + QMessageBox::information(this, + "Properties", + "Selected object type does not have a properties dialog."); + return; + } +} + +void W3DViewMainWindow::setNpatchesLevel(int level) +{ + if (level < 1) { + level = 1; + } + if (level > 8) { + level = 8; + } + + WW3D::Set_NPatches_Level(static_cast(level)); + + QSettings settings; + settings.setValue("Config/NPatchesSubdivision", level); +} + +void W3DViewMainWindow::toggleNpatchesGap(bool enabled) +{ + WW3D::Set_NPatches_Gap_Filling_Mode( + enabled ? WW3D::NPATCHES_GAP_FILLING_ENABLED + : WW3D::NPATCHES_GAP_FILLING_DISABLED); + + QSettings settings; + settings.setValue("Config/NPatchesGapFilling", enabled ? 1 : 0); +} + +void W3DViewMainWindow::reloadLightmapModels() +{ + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager || !_treeModel) { + return; + } + + auto *root = _treeModel->invisibleRootItem(); + if (!root) { + return; + } + + auto matches_group = [](const QString &text, const QString &label) { + return text == label || text.startsWith(label + " ("); + }; + + auto remove_child_prototypes = [&](const QString &label) { + QStandardItem *group = nullptr; + const int root_count = root->rowCount(); + for (int i = 0; i < root_count; ++i) { + auto *child = root->child(i); + if (!child) { + continue; + } + if (matches_group(child->text(), label)) { + group = child; + break; + } + } + + if (!group) { + return; + } + + const int count = group->rowCount(); + for (int index = 0; index < count; ++index) { + auto *item = group->child(index); + if (!item) { + continue; + } + if (item->data(kRoleType).toInt() != static_cast(AssetNodeType::RenderObject)) { + continue; + } + const QString name = item->data(kRoleName).toString(); + if (name.isEmpty()) { + continue; + } + const QByteArray name_bytes = name.toLatin1(); + asset_manager->Remove_Prototype(name_bytes.constData()); + } + }; + + remove_child_prototypes("Mesh"); + remove_child_prototypes("Hierarchy"); + remove_child_prototypes("Mesh Collection"); +} + +void W3DViewMainWindow::reloadDisplayedObject() +{ + if (!_treeView) { + return; + } + + const QModelIndex current = _treeView->currentIndex(); + if (current.isValid()) { + onCurrentChanged(current, QModelIndex()); + } +} + +bool W3DViewMainWindow::loadAssetsFromFile(const QString &path) +{ + if (path.isEmpty()) { + return false; + } + + const QFileInfo info(path); + if (!info.exists() || !info.isFile()) { + QMessageBox::warning(this, "W3DViewQt", QString("File not found:\n%1").arg(path)); + return false; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + QMessageBox::warning(this, "W3DViewQt", "WW3D asset manager is not available."); + return false; + } + + asset_manager->Load_Procedural_Textures(); + + const QString directory = QFileInfo(path).absolutePath(); + if (!directory.isEmpty()) { + QDir::setCurrent(directory); + applyTexturePath(directory); + } + + const QByteArray path_bytes = QDir::toNativeSeparators(path).toLocal8Bit(); + if (!asset_manager->Load_3D_Assets(path_bytes.constData())) { + QMessageBox::warning(this, "W3DViewQt", "Failed to load W3D assets."); + return false; + } + + LoadMissingHierarchyAssets(asset_manager, directory); + + _lastOpenedPath = info.absolutePath(); + const QString absolute_path = info.absoluteFilePath(); + const auto already_loaded = std::find_if( + _loadedFiles.cbegin(), _loadedFiles.cend(), [&absolute_path](const QString &loaded) { + return loaded.compare(absolute_path, Qt::CaseInsensitive) == 0; + }); + if (already_loaded == _loadedFiles.cend()) { + _loadedFiles.append(absolute_path); + } + QSettings settings; + settings.setValue("Config/LastOpenedPath", _lastOpenedPath); + setWindowTitle(QString("W3DViewQt - %1").arg(info.fileName())); + rebuildAssetTree(); + addRecentFile(info.absoluteFilePath()); + statusBar()->showMessage(QString("Loaded: %1").arg(info.fileName())); + return true; +} + +void W3DViewMainWindow::updateRecentFilesMenu() +{ + if (!_fileMenu || !_recentFilesPlaceholderAction) { + return; + } + + for (QAction *action : _recentFileActions) { + _fileMenu->removeAction(action); + action->setObjectName({}); + action->deleteLater(); + } + _recentFileActions.clear(); + + QSettings settings; + const auto files = + qtcommon::ReadRecentFiles(settings, QStringLiteral("recentFiles"), kMaxRecentFiles); + if (settings.value(QStringLiteral("recentFiles")).toStringList() != files) { + qtcommon::WriteRecentFiles( + settings, files, QStringLiteral("recentFiles"), kMaxRecentFiles); + } + _recentFilesPlaceholderAction->setVisible(files.isEmpty()); + _recentFilesPlaceholderAction->setEnabled(false); + if (files.isEmpty()) { + return; + } + + int index = 1; + for (const QString &path : files) { + const QFileInfo info(path); + const int item_index = index++; + const QString label = QString("&%1 %2").arg(item_index).arg(info.fileName()); + auto *action = new QAction(label, _fileMenu); + action->setObjectName(QString("recentFileAction%1").arg(item_index)); + action->setData(path); + action->setToolTip(path); + connect(action, &QAction::triggered, this, &W3DViewMainWindow::openRecentFile); + _fileMenu->insertAction(_recentFilesPlaceholderAction, action); + _recentFileActions.append(action); + } +} + +void W3DViewMainWindow::addRecentFile(const QString &path) +{ + if (path.isEmpty()) { + return; + } + + QSettings settings; + const QStringList files = qtcommon::AddRecentFile( + qtcommon::ReadRecentFiles(settings, QStringLiteral("recentFiles"), kMaxRecentFiles), + path, + kMaxRecentFiles); + qtcommon::WriteRecentFiles(settings, files, QStringLiteral("recentFiles"), kMaxRecentFiles); + updateRecentFilesMenu(); +} + +void W3DViewMainWindow::rebuildAssetTree() +{ + _treeModel->clear(); + _treeModel->setHorizontalHeaderLabels(QStringList() << "Assets"); + + auto *root = _treeModel->invisibleRootItem(); + auto *materials_group = new QStandardItem("Materials"); + materials_group->setEditable(false); + materials_group->setData(static_cast(AssetNodeType::Group), kRoleType); + root->appendRow(materials_group); + + auto *mesh_group = new QStandardItem("Mesh"); + mesh_group->setEditable(false); + mesh_group->setData(static_cast(AssetNodeType::Group), kRoleType); + root->appendRow(mesh_group); + + auto *hierarchy_group = new QStandardItem("Hierarchy"); + hierarchy_group->setEditable(false); + hierarchy_group->setData(static_cast(AssetNodeType::Group), kRoleType); + root->appendRow(hierarchy_group); + + auto *hlod_group = new QStandardItem("H-LOD"); + hlod_group->setEditable(false); + hlod_group->setData(static_cast(AssetNodeType::Group), kRoleType); + root->appendRow(hlod_group); + + auto *collection_group = new QStandardItem("Mesh Collection"); + collection_group->setEditable(false); + collection_group->setData(static_cast(AssetNodeType::Group), kRoleType); + root->appendRow(collection_group); + + auto *aggregate_group = new QStandardItem("Aggregate"); + aggregate_group->setEditable(false); + aggregate_group->setData(static_cast(AssetNodeType::Group), kRoleType); + root->appendRow(aggregate_group); + + auto *emitter_group = new QStandardItem("Emitter"); + emitter_group->setEditable(false); + emitter_group->setData(static_cast(AssetNodeType::Group), kRoleType); + root->appendRow(emitter_group); + + auto *primitives_group = new QStandardItem("Primitives"); + primitives_group->setEditable(false); + primitives_group->setData(static_cast(AssetNodeType::Group), kRoleType); + root->appendRow(primitives_group); + + auto *sound_group = new QStandardItem("Sounds"); + sound_group->setEditable(false); + sound_group->setData(static_cast(AssetNodeType::Group), kRoleType); + root->appendRow(sound_group); + + addMaterialItems(materials_group); + addRenderObjectItems(mesh_group, + hierarchy_group, + hlod_group, + collection_group, + aggregate_group, + emitter_group, + primitives_group, + sound_group); + addAnimationItems(hierarchy_group, hlod_group, aggregate_group); + + materials_group->sortChildren(0, Qt::AscendingOrder); + mesh_group->sortChildren(0, Qt::AscendingOrder); + hierarchy_group->sortChildren(0, Qt::AscendingOrder); + hlod_group->sortChildren(0, Qt::AscendingOrder); + collection_group->sortChildren(0, Qt::AscendingOrder); + aggregate_group->sortChildren(0, Qt::AscendingOrder); + emitter_group->sortChildren(0, Qt::AscendingOrder); + primitives_group->sortChildren(0, Qt::AscendingOrder); + sound_group->sortChildren(0, Qt::AscendingOrder); + SortAnimationChildren(hierarchy_group); + SortAnimationChildren(hlod_group); + SortAnimationChildren(aggregate_group); + + _treeView->setExpanded(materials_group->index(), _autoExpandAssetTree); + _treeView->setExpanded(mesh_group->index(), _autoExpandAssetTree); + _treeView->setExpanded(hierarchy_group->index(), _autoExpandAssetTree); + _treeView->setExpanded(hlod_group->index(), _autoExpandAssetTree); + _treeView->setExpanded(collection_group->index(), _autoExpandAssetTree); + _treeView->setExpanded(aggregate_group->index(), _autoExpandAssetTree); + _treeView->setExpanded(emitter_group->index(), _autoExpandAssetTree); + _treeView->setExpanded(primitives_group->index(), _autoExpandAssetTree); + _treeView->setExpanded(sound_group->index(), _autoExpandAssetTree); +} + +void W3DViewMainWindow::addMaterialItems(QStandardItem *parent) +{ + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + return; + } + + int count = 0; + HashTemplateIterator iterator(asset_manager->Texture_Hash()); + for (iterator.First(); !iterator.Is_Done(); iterator.Next()) { + const char *name = iterator.Peek_Key(); + if (!name || !name[0]) { + continue; + } + + auto *texture = iterator.Peek_Value(); + auto *item = new QStandardItem(QString::fromLatin1(name)); + item->setEditable(false); + item->setData(static_cast(AssetNodeType::Material), kRoleType); + item->setData(QString::fromLatin1(name), kRoleName); + item->setData(QVariant::fromValue(reinterpret_cast(texture)), kRolePointer); + parent->appendRow(item); + ++count; + } + + parent->setText(QString("Materials (%1)").arg(count)); +} + +void W3DViewMainWindow::addRenderObjectItems(QStandardItem *meshParent, + QStandardItem *hierarchyParent, + QStandardItem *hlodParent, + QStandardItem *collectionParent, + QStandardItem *aggregateParent, + QStandardItem *emitterParent, + QStandardItem *primitivesParent, + QStandardItem *soundParent) +{ + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + return; + } + + RenderObjIterator *iterator = asset_manager->Create_Render_Obj_Iterator(); + if (!iterator) { + return; + } + + struct RenderObjectEntry + { + QString name; + int class_id; + }; + + QVector render_objects; + for (iterator->First(); !iterator->Is_Done(); iterator->Next()) { + const char *name = iterator->Current_Item_Name(); + if (!name || !name[0] || !asset_manager->Render_Obj_Exists(name)) { + continue; + } + + render_objects.push_back( + RenderObjectEntry{QString::fromLatin1(name), iterator->Current_Item_Class_ID()}); + } + + // Removing a prototype mutates the vector traversed by RenderObjIterator. + // Snapshot first and release the iterator before converting old DISTLODs. + asset_manager->Release_Render_Obj_Iterator(iterator); + for (RenderObjectEntry &entry : render_objects) { + if (entry.class_id == RenderObjClass::CLASSID_DISTLOD && + ConvertDistLodPrototype(asset_manager, entry.name)) { + entry.class_id = RenderObjClass::CLASSID_HLOD; + } + } + + int mesh_count = 0; + int hierarchy_count = 0; + int hlod_count = 0; + int collection_count = 0; + int aggregate_count = 0; + int emitter_count = 0; + int primitive_count = 0; + int sound_count = 0; + for (const RenderObjectEntry &entry : render_objects) { + const QByteArray name_bytes = entry.name.toLatin1(); + const char *name = name_bytes.constData(); + if (!asset_manager->Render_Obj_Exists(name)) { + continue; + } + + const int class_id = entry.class_id; + QStandardItem *parent = nullptr; + bool insert = false; + + switch (class_id) { + case RenderObjClass::CLASSID_COLLECTION: + insert = true; + parent = collectionParent; + break; + case RenderObjClass::CLASSID_MESH: + insert = true; + parent = meshParent; + break; + case RenderObjClass::CLASSID_SOUND: + insert = true; + parent = soundParent; + break; + case RenderObjClass::CLASSID_PARTICLEEMITTER: + insert = true; + parent = emitterParent; + break; + case RenderObjClass::CLASSID_SPHERE: + case RenderObjClass::CLASSID_RING: + insert = true; + parent = primitivesParent; + break; + case RenderObjClass::CLASSID_DISTLOD: + case RenderObjClass::CLASSID_HLOD: + insert = true; + parent = hierarchyParent; + break; + case RenderObjClass::CLASSID_HMODEL: + insert = true; + parent = hierarchyParent; + break; + default: + break; + } + + if (!insert || !parent) { + continue; + } + + const RenderObjInfo info = InspectRenderObj(name); + if ((class_id == RenderObjClass::CLASSID_DISTLOD || + class_id == RenderObjClass::CLASSID_HLOD) && + info.isRealLod) { + parent = hlodParent; + } + + if (info.isAggregate) { + parent = aggregateParent; + } + + auto *item = new QStandardItem(entry.name); + item->setEditable(false); + item->setData(static_cast(AssetNodeType::RenderObject), kRoleType); + item->setData(entry.name, kRoleName); + item->setData(class_id, kRoleClassId); + if (!info.hierarchyName.isEmpty()) { + item->setData(info.hierarchyName, kRoleHierarchyName); + } + parent->appendRow(item); + + if (parent == meshParent) { + ++mesh_count; + } else if (parent == hierarchyParent) { + ++hierarchy_count; + } else if (parent == hlodParent) { + ++hlod_count; + } else if (parent == collectionParent) { + ++collection_count; + } else if (parent == aggregateParent) { + ++aggregate_count; + } else if (parent == emitterParent) { + ++emitter_count; + } else if (parent == primitivesParent) { + ++primitive_count; + } else if (parent == soundParent) { + ++sound_count; + } + } + + meshParent->setText(QString("Mesh (%1)").arg(mesh_count)); + hierarchyParent->setText(QString("Hierarchy (%1)").arg(hierarchy_count)); + hlodParent->setText(QString("H-LOD (%1)").arg(hlod_count)); + collectionParent->setText(QString("Mesh Collection (%1)").arg(collection_count)); + aggregateParent->setText(QString("Aggregate (%1)").arg(aggregate_count)); + emitterParent->setText(QString("Emitter (%1)").arg(emitter_count)); + primitivesParent->setText(QString("Primitives (%1)").arg(primitive_count)); + soundParent->setText(QString("Sounds (%1)").arg(sound_count)); +} + +void W3DViewMainWindow::addAnimationItems(QStandardItem *hierarchyParent, + QStandardItem *hlodParent, + QStandardItem *aggregateParent) +{ + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + return; + } + + AssetIterator *iterator = asset_manager->Create_HAnim_Iterator(); + if (!iterator) { + return; + } + + for (iterator->First(); !iterator->Is_Done(); iterator->Next()) { + const char *anim_name = iterator->Current_Item_Name(); + if (!anim_name || !anim_name[0]) { + continue; + } + + HAnimClass *anim = asset_manager->Get_HAnim(anim_name); + if (!anim) { + continue; + } + + const char *hier_name = anim->Get_HName(); + const QString hierarchy = hier_name ? QString::fromLatin1(hier_name) : QString(); + anim->Release_Ref(); + + if (hierarchy.isEmpty()) { + continue; + } + + QVector targets; + if (_restrictAnims) { + CollectHierarchyItems(hierarchyParent, hierarchy, targets); + CollectHierarchyItems(hlodParent, hierarchy, targets); + CollectHierarchyItems(aggregateParent, hierarchy, targets); + } else { + CollectAllChildren(hierarchyParent, targets); + CollectAllChildren(hlodParent, targets); + CollectAllChildren(aggregateParent, targets); + } + + if (targets.isEmpty()) { + continue; + } + + const QString anim_text = QString::fromLatin1(anim_name); + for (auto *target : targets) { + if (!target) { + continue; + } + + auto *item = new QStandardItem(anim_text); + item->setEditable(false); + item->setData(static_cast(AssetNodeType::Animation), kRoleType); + item->setData(anim_text, kRoleName); + target->appendRow(item); + } + } + + delete iterator; +} + +void W3DViewMainWindow::loadAppSettings() +{ + QSettings settings; + const QByteArray geometry = settings.value("Window/Geometry").toByteArray(); + if (!geometry.isEmpty()) { + restoreGeometry(geometry); + } + const QByteArray window_state = settings.value("Window/State").toByteArray(); + if (!window_state.isEmpty()) { + restoreState(window_state); + } + _lastOpenedPath = settings.value("Config/LastOpenedPath").toString(); + _texturePath1 = settings.value("Config/TexturePath1").toString(); + _texturePath2 = settings.value("Config/TexturePath2").toString(); + _sortingEnabled = settings.value("Config/EnableSorting", true).toBool(); + _animateCamera = settings.value("Config/AnimateCamera", false).toBool(); + _autoResetCamera = settings.value("Config/ResetCamera", true).toBool(); + _autoExpandAssetTree = settings.value("Config/AutoExpandAssetTree", true).toBool(); + const bool invert_culling = settings.value("Config/InvertBackfaceCulling", false).toBool(); + const bool manual_fov = settings.value("Config/UseManualFOV", false).toBool(); + const bool manual_clip = settings.value("Config/UseManualClipPlanes", false).toBool(); + const double hfov_rad = settings.value("Config/hfov", 0.0).toDouble(); + const double vfov_rad = settings.value("Config/vfov", 0.0).toDouble(); + const float znear = settings.value("Config/znear", 0.1).toFloat(); + const float zfar = settings.value("Config/zfar", 100.0).toFloat(); + const int device_width = settings.value("Config/DeviceWidth", 0).toInt(); + const int device_height = settings.value("Config/DeviceHeight", 0).toInt(); + const int device_bits_per_pixel = settings.value("Config/DeviceBitsPerPix", 32).toInt(); + const bool fullscreen = settings.value("Config/Windowed", 1).toInt() == 0; + const bool gamma_enabled = settings.value("Config/EnableGamma", 0).toInt() != 0; + const bool munge_sort = settings.value("Config/MungeSortOnLoad", 0).toInt() != 0; + int npatches_level = settings.value("Config/NPatchesSubdivision", 4).toInt(); + if (npatches_level < 1) { + npatches_level = 1; + } + if (npatches_level > 8) { + npatches_level = 8; + } + const bool npatches_gap = settings.value("Config/NPatchesGapFilling", 0).toInt() != 0; + WW3D::Enable_Sorting(_sortingEnabled); + + applyTexturePath(_texturePath1); + applyTexturePath(_texturePath2); + + if (_viewport) { + _viewport->setInitialDisplayMode( + device_width, device_height, device_bits_per_pixel, fullscreen); + _viewport->setCameraAnimationEnabled(_animateCamera); + _viewport->setAutoResetEnabled(_autoResetCamera); + _viewport->setManualFovEnabled(manual_fov); + if (manual_fov && hfov_rad > 0.0 && vfov_rad > 0.0) { + _viewport->setCameraFovDegrees(hfov_rad * kRadToDeg, vfov_rad * kRadToDeg); + } + _viewport->setManualClipPlanesEnabled(manual_clip); + if (manual_clip) { + _viewport->setCameraClipPlanes(znear, zfar); + } + } + + if (fullscreen) { + setWindowState(windowState() | Qt::WindowFullScreen); + } else { + setWindowState(windowState() & ~Qt::WindowFullScreen); + } + + if (_enableGammaAction) { + _enableGammaAction->setChecked(gamma_enabled); + } + if (_mungeSortAction) { + _mungeSortAction->setChecked(munge_sort); + } + if (_autoExpandTreeAction) { + _autoExpandTreeAction->setChecked(_autoExpandAssetTree); + } + if (gamma_enabled) { + int gamma = settings.value("Config/Gamma", 10).toInt(); + if (gamma < 10) { + gamma = 10; + } + if (gamma > 30) { + gamma = 30; + } + DX8Wrapper::Set_Gamma(gamma / 10.0f, 0.0f, 1.0f); + } + + ShaderClass::Invert_Backface_Culling(invert_culling); + WW3D::Enable_Munge_Sort_On_Load(munge_sort); + + WW3D::Set_NPatches_Level(static_cast(npatches_level)); + WW3D::Set_NPatches_Gap_Filling_Mode( + npatches_gap ? WW3D::NPATCHES_GAP_FILLING_ENABLED + : WW3D::NPATCHES_GAP_FILLING_DISABLED); + if (_npatchesGroup) { + for (auto *action : _npatchesGroup->actions()) { + if (action && action->data().toInt() == npatches_level) { + action->setChecked(true); + break; + } + } + } + if (_npatchesGapAction) { + _npatchesGapAction->setChecked(npatches_gap); + } +} + +void W3DViewMainWindow::loadDefaultSettings() +{ + const QString default_path = QDir(QCoreApplication::applicationDirPath()).filePath("default.dat"); + if (!QFileInfo::exists(default_path)) { + return; + } + + loadSettingsPath(default_path); +} + +void W3DViewMainWindow::playAnimationSound() +{ + const QString animation_name = _viewport ? _viewport->currentAnimationName() : QString(); + const qsizetype separator = animation_name.indexOf('.'); + if (separator < 0 || separator + 1 >= animation_name.size()) { + return; + } + + const QString sound_filename = animation_name.mid(separator + 1) + ".wav"; + stopAnimationSound(); + + WWAudioClass *audio = WWAudioClass::Get_Instance(); + if (!audio) { + return; + } + + const QString native_filename = QDir::toNativeSeparators(sound_filename); + const QByteArray filename_bytes = QFile::encodeName(native_filename); + _animationSound = audio->Create_Sound_Effect(filename_bytes.constData()); + if (_animationSound && !_animationSound->Play()) { + _animationSound->Release_Ref(); + _animationSound = nullptr; + } +} + +void W3DViewMainWindow::stopAnimationSound() +{ + if (!_animationSound) { + return; + } + + _animationSound->Stop(); + _animationSound->Release_Ref(); + _animationSound = nullptr; +} + +void W3DViewMainWindow::applyMainToolbarIcons() +{ + const QPixmap strip(":/w3dview/main-toolbar.bmp"); + if (strip.isNull() || strip.width() < 16 || strip.height() < 15) { + return; + } + + const QList actions = { + _newAction, + _openAction, + _exportEmitterAction, + _exportAggregateAction, + _exportLodAction, + _exportPrimitiveAction, + _exportSoundObjectAction, + _listMissingTexturesAction, + _copyAssetsAction, + _addToLineupAction, + _aboutAction, + }; + const int action_count = static_cast(actions.size()); + const int icon_width = strip.width() / action_count; + for (int index = 0; index < action_count; ++index) { + QAction *action = actions[index]; + if (!action) { + continue; + } + QPixmap icon = strip.copy(index * icon_width, 0, icon_width, strip.height()); + const QColor mask_color = icon.toImage().pixelColor(0, 0); + icon.setMask(icon.createMaskFromColor(mask_color, Qt::MaskInColor)); + action->setIcon(QIcon(icon)); + } +} + +void W3DViewMainWindow::applyTexturePath(const QString &path) +{ + const QString cleaned = NormalizeOptionalPath(path); + if (cleaned.isEmpty() || !_TheSimpleFileFactory) { + return; + } + + const QByteArray native = QDir::toNativeSeparators(cleaned).toLocal8Bit(); + _TheSimpleFileFactory->Append_Sub_Directory(native.constData()); +} + +void W3DViewMainWindow::setTexturePaths(const QString &path1, const QString &path2) +{ + QSettings settings; + + const QString cleaned1 = NormalizeOptionalPath(path1); + if (cleaned1.compare(_texturePath1, Qt::CaseInsensitive) != 0) { + applyTexturePath(cleaned1); + _texturePath1 = cleaned1; + settings.setValue("Config/TexturePath1", _texturePath1); + } + + const QString cleaned2 = NormalizeOptionalPath(path2); + if (cleaned2.compare(_texturePath2, Qt::CaseInsensitive) != 0) { + applyTexturePath(cleaned2); + _texturePath2 = cleaned2; + settings.setValue("Config/TexturePath2", _texturePath2); + } +} + +void W3DViewMainWindow::applySettings(QSettings &settings) +{ + if (!_viewport) { + return; + } + + settings.beginGroup("Settings"); + + if (settings.contains("AmbientLightR") && settings.contains("AmbientLightG") && + settings.contains("AmbientLightB")) { + const float amb_r = settings.value("AmbientLightR").toFloat(); + const float amb_g = settings.value("AmbientLightG").toFloat(); + const float amb_b = settings.value("AmbientLightB").toFloat(); + _viewport->setAmbientLight(Vector3(amb_r, amb_g, amb_b)); + } + + const bool has_legacy_scene_light = settings.contains("SceneLightR") + && settings.contains("SceneLightG") && settings.contains("SceneLightB"); + const Vector3 legacy_scene_light( + settings.value("SceneLightR", 1.0f).toFloat(), + settings.value("SceneLightG", 1.0f).toFloat(), + settings.value("SceneLightB", 1.0f).toFloat()); + const bool has_scene_light_diffuse = settings.contains("SceneLightDiffuseR") + && settings.contains("SceneLightDiffuseG") && settings.contains("SceneLightDiffuseB"); + const bool has_scene_light_specular = settings.contains("SceneLightSpecularR") + && settings.contains("SceneLightSpecularG") && settings.contains("SceneLightSpecularB"); + const Vector3 stored_scene_light_diffuse( + settings.value("SceneLightDiffuseR", legacy_scene_light.X).toFloat(), + settings.value("SceneLightDiffuseG", legacy_scene_light.Y).toFloat(), + settings.value("SceneLightDiffuseB", legacy_scene_light.Z).toFloat()); + const bool legacy_scene_light_was_updated = has_legacy_scene_light && has_scene_light_diffuse + && (legacy_scene_light.X != stored_scene_light_diffuse.X + || legacy_scene_light.Y != stored_scene_light_diffuse.Y + || legacy_scene_light.Z != stored_scene_light_diffuse.Z); + + if (has_legacy_scene_light && (!has_scene_light_diffuse || legacy_scene_light_was_updated)) { + // A legacy/MFC writer only updates SceneLightR/G/B. When those values no + // longer mirror the stored diffuse channel, honor that update for both + // channels instead of resurrecting stale Qt-only values. + _viewport->setSceneLightSpecular(legacy_scene_light); + _viewport->setSceneLightDiffuse(legacy_scene_light); + } else { + if (has_scene_light_diffuse) { + _viewport->setSceneLightDiffuse(stored_scene_light_diffuse); + } + if (has_scene_light_specular) { + _viewport->setSceneLightSpecular(Vector3( + settings.value("SceneLightSpecularR").toFloat(), + settings.value("SceneLightSpecularG").toFloat(), + settings.value("SceneLightSpecularB").toFloat())); + } else if (has_legacy_scene_light) { + _viewport->setSceneLightSpecular(legacy_scene_light); + } + } + + if (settings.contains("SceneLightX") && settings.contains("SceneLightY") && + settings.contains("SceneLightZ") && settings.contains("SceneLightW")) { + Quaternion orientation(true); + orientation.X = settings.value("SceneLightX").toFloat(); + orientation.Y = settings.value("SceneLightY").toFloat(); + orientation.Z = settings.value("SceneLightZ").toFloat(); + orientation.W = settings.value("SceneLightW").toFloat(); + _viewport->setSceneLightOrientation(orientation); + } + + if (settings.contains("SceneLightDistance") && settings.contains("SceneLightIntensity") && + settings.contains("SceneLightAttenStart") && settings.contains("SceneLightAttenEnd") && + settings.contains("SceneLightAttenOn")) { + const float distance = settings.value("SceneLightDistance").toFloat(); + const float intensity = settings.value("SceneLightIntensity").toFloat(); + const float atten_start = settings.value("SceneLightAttenStart").toFloat(); + const float atten_end = settings.value("SceneLightAttenEnd").toFloat(); + const bool atten_on = settings.value("SceneLightAttenOn").toBool(); + _viewport->setSceneLightIntensity(intensity); + _viewport->setSceneLightAttenuation(atten_start, atten_end, atten_on); + _viewport->setSceneLightDistance(distance); + } + + if (settings.contains("BackgroundR") && settings.contains("BackgroundG") && + settings.contains("BackgroundB")) { + const float bg_r = settings.value("BackgroundR").toFloat(); + const float bg_g = settings.value("BackgroundG").toFloat(); + const float bg_b = settings.value("BackgroundB").toFloat(); + _viewport->setBackgroundColor(Vector3(bg_r, bg_g, bg_b)); + } + + if (settings.contains("BackgroundBMP")) { + _viewport->setBackgroundBitmap(settings.value("BackgroundBMP").toString()); + } + + if (settings.contains("FogEnabled")) { + _viewport->setFogEnabled(settings.value("FogEnabled").toBool()); + } + + settings.endGroup(); + if (_fogAction) { + _fogAction->setChecked(_viewport->isFogEnabled()); + } +} + +void W3DViewMainWindow::writeSettings(QSettings &settings, + bool saveLighting, + bool saveBackground) const +{ + if (!_viewport) { + return; + } + + settings.beginGroup("Settings"); + + if (saveLighting) { + const Vector3 ambient = _viewport->ambientLight(); + settings.setValue("AmbientLightR", ambient.X); + settings.setValue("AmbientLightG", ambient.Y); + settings.setValue("AmbientLightB", ambient.Z); + + const Vector3 scene_light_diffuse = _viewport->sceneLightDiffuse(); + const Vector3 scene_light_specular = _viewport->sceneLightSpecular(); + settings.setValue("SceneLightR", scene_light_diffuse.X); + settings.setValue("SceneLightG", scene_light_diffuse.Y); + settings.setValue("SceneLightB", scene_light_diffuse.Z); + settings.setValue("SceneLightDiffuseR", scene_light_diffuse.X); + settings.setValue("SceneLightDiffuseG", scene_light_diffuse.Y); + settings.setValue("SceneLightDiffuseB", scene_light_diffuse.Z); + settings.setValue("SceneLightSpecularR", scene_light_specular.X); + settings.setValue("SceneLightSpecularG", scene_light_specular.Y); + settings.setValue("SceneLightSpecularB", scene_light_specular.Z); + + const Quaternion orientation = _viewport->sceneLightOrientation(); + settings.setValue("SceneLightX", orientation.X); + settings.setValue("SceneLightY", orientation.Y); + settings.setValue("SceneLightZ", orientation.Z); + settings.setValue("SceneLightW", orientation.W); + + settings.setValue("SceneLightDistance", _viewport->sceneLightDistance()); + settings.setValue("SceneLightIntensity", _viewport->sceneLightIntensity()); + + float atten_start = 0.0f; + float atten_end = 0.0f; + bool atten_on = false; + _viewport->sceneLightAttenuation(atten_start, atten_end, atten_on); + settings.setValue("SceneLightAttenStart", atten_start); + settings.setValue("SceneLightAttenEnd", atten_end); + settings.setValue("SceneLightAttenOn", atten_on ? 1 : 0); + } + + if (saveBackground) { + const Vector3 background = _viewport->backgroundColor(); + settings.setValue("BackgroundR", background.X); + settings.setValue("BackgroundG", background.Y); + settings.setValue("BackgroundB", background.Z); + settings.setValue("BackgroundBMP", _viewport->backgroundBitmap()); + settings.setValue("FogEnabled", _viewport->isFogEnabled()); + } + + settings.endGroup(); +} diff --git a/Code/Tools/W3DViewQt/MainWindow.h b/Code/Tools/W3DViewQt/MainWindow.h new file mode 100644 index 000000000..d97b193b0 --- /dev/null +++ b/Code/Tools/W3DViewQt/MainWindow.h @@ -0,0 +1,313 @@ +#pragma once + +#include +#include +#include +#include + +class QAction; +class QActionGroup; +class QCloseEvent; +class QLabel; +class QDragEnterEvent; +class QDropEvent; +class QModelIndex; +class QPoint; +class QMenu; +class QSettings; +class QStandardItem; +class QStandardItemModel; +class QToolBar; +class QTimer; +class QTreeView; +class AudibleSoundClass; +class ParticleEmitterDefClass; +class W3DViewport; + +namespace Ui { +class W3DViewMainWindow; +} + +class W3DViewMainWindow final : public QMainWindow +{ + Q_OBJECT + +public: + explicit W3DViewMainWindow(QWidget *parent = nullptr); + ~W3DViewMainWindow() override; + bool openFilePath(const QString &path); + bool loadSettingsPath(const QString &path); + +protected: + void closeEvent(QCloseEvent *event) override; + void dragEnterEvent(QDragEnterEvent *event) override; + void dropEvent(QDropEvent *event) override; + +private slots: + void newFile(); + void openFile(); + void openRecentFile(); + void openTexturePathsDialog(); + void loadSettingsFile(); + void saveSettingsFile(); + void onCurrentChanged(const QModelIndex ¤t, const QModelIndex &previous); + void toggleWireframe(bool enabled); + void toggleSorting(bool enabled); + void toggleRestrictAnims(bool enabled); + void toggleStatusBar(bool visible); + void toggleBackfaceCulling(bool inverted); + void setAmbientLight(); + void setSceneLight(); + void increaseAmbientLight(); + void decreaseAmbientLight(); + void increaseSceneLight(); + void decreaseSceneLight(); + void killSceneLight(); + void toggleLightRotateY(bool enabled); + void toggleLightRotateYBack(); + void toggleLightRotateZ(bool enabled); + void toggleLightRotateZBack(); + void toggleExposePrelit(bool enabled); + void setPrelitVertex(); + void setPrelitMultipass(); + void setPrelitMultitex(); + void setBackgroundColor(); + void setBackgroundBitmap(); + void toggleFog(bool enabled); + void setCameraFront(); + void setCameraBack(); + void setCameraLeft(); + void setCameraRight(); + void setCameraTop(); + void setCameraBottom(); + void resetCamera(); + void setCameraRotateX(bool enabled); + void setCameraRotateY(bool enabled); + void setCameraRotateZ(bool enabled); + void toggleCameraAnimate(bool enabled); + void toggleCameraResetOnDisplay(bool enabled); + void toggleCameraBonePosX(bool enabled); + void openCameraSettings(); + void openCameraDistance(); + void copyScreenSize(); + void changeResolution(); + void openGammaDialog(); + void toggleGammaCorrection(bool enabled); + void toggleMungeSortOnLoad(bool enabled); + void toggleAutoExpandAssetTree(bool enabled); + void openBackgroundObjectDialog(); + void captureScreenshot(); + void makeMovie(); + void selectPrevAsset(); + void selectNextAsset(); + void showTreeContextMenu(const QPoint &pos); + void startAnimation(); + void pauseAnimation(); + void stopAnimation(); + void stepAnimationForward(); + void stepAnimationBackward(); + void openAnimationSettings(); + void openAdvancedAnimation(); + void generateLod(); + void makeAggregate(); + void renameAggregate(); + void openBoneManagement(); + void autoAssignBoneModels(); + void bindSubobjectLod(); + void createEmitter(); + void scaleEmitter(); + void editEmitter(); + void createSphere(); + void createRing(); + void editPrimitive(); + void createSoundObject(); + void editSoundObject(); + void openAnimatedSoundOptions(); + void importFacialAnims(); + void exportAggregate(); + void exportEmitter(); + void exportLod(); + void exportPrimitive(); + void exportSoundObject(); + void listMissingTextures(); + void copyAssets(); + void addToLineup(); + void showAbout(); + void toggleMainToolbar(bool visible); + void toggleObjectToolbar(bool visible); + void toggleAnimationToolbar(bool visible); + void recordLodScreenArea(); + void toggleLodIncludeNull(bool enabled); + void selectPrevLod(); + void selectNextLod(); + void toggleLodAutoSwitch(bool enabled); + void toggleObjectRotateX(bool enabled); + void toggleObjectRotateY(bool enabled); + void toggleObjectRotateYBack(); + void toggleObjectRotateZ(bool enabled); + void toggleObjectRotateZBack(); + void resetObject(); + void toggleAlternateMaterials(); + void showObjectProperties(); + void setNpatchesLevel(int level); + void toggleNpatchesGap(bool enabled); + void updateStatusBar(); + +private: + Ui::W3DViewMainWindow *_ui = nullptr; + void updateSpecialMenu(const QModelIndex ¤t); + void updateEmittersEditMenu(); + void refreshAnimationMenu(); + void refreshAggregateMenu(); + void refreshLodMenu(); + void editEmitterByName(const QString &name); + bool commitEmitterDefinition(const ParticleEmitterDefClass &definition, + const QString ®isteredName, + bool reloadCurrentObject, + bool attachedToAggregate); + void applySettings(QSettings &settings); + void writeSettings(QSettings &settings, bool saveLighting, bool saveBackground) const; + void loadQuickSettings(int slot); + void cyclePaneFocus(bool reverse); + void playAnimationSound(); + void stopAnimationSound(); + void applyMainToolbarIcons(); + bool loadAssetsFromFile(const QString &path); + void rebuildAssetTree(); + void addMaterialItems(QStandardItem *parent); + void addRenderObjectItems(QStandardItem *meshParent, + QStandardItem *hierarchyParent, + QStandardItem *hlodParent, + QStandardItem *collectionParent, + QStandardItem *aggregateParent, + QStandardItem *emitterParent, + QStandardItem *primitivesParent, + QStandardItem *soundParent); + void addAnimationItems(QStandardItem *hierarchyParent, + QStandardItem *hlodParent, + QStandardItem *aggregateParent); + void loadAppSettings(); + void loadDefaultSettings(); + void applyTexturePath(const QString &path); + void setTexturePaths(const QString &path1, const QString &path2); + void reloadLightmapModels(); + void reloadDisplayedObject(); + void updateRecentFilesMenu(); + void addRecentFile(const QString &path); + bool confirmExportTarget(const QString &path); + + QTreeView *_treeView = nullptr; + QStandardItemModel *_treeModel = nullptr; + W3DViewport *_viewport = nullptr; + AudibleSoundClass *_animationSound = nullptr; + QMenu *_fileMenu = nullptr; + QMenu *_animationMenu = nullptr; + QMenu *_hierarchyMenu = nullptr; + QMenu *_aggregateMenu = nullptr; + QMenu *_lodMenu = nullptr; + QMenu *_emittersEditMenu = nullptr; + QLabel *_statusPolysLabel = nullptr; + QLabel *_statusParticlesLabel = nullptr; + QLabel *_statusCameraLabel = nullptr; + QLabel *_statusFramesLabel = nullptr; + QLabel *_statusFpsLabel = nullptr; + QLabel *_statusResolutionLabel = nullptr; + QTimer *_statusTimer = nullptr; + QToolBar *_mainToolbar = nullptr; + QToolBar *_objectToolbar = nullptr; + QToolBar *_animationToolbar = nullptr; + QAction *_toolbarMainAction = nullptr; + QAction *_toolbarObjectAction = nullptr; + QAction *_toolbarAnimationAction = nullptr; + QAction *_newAction = nullptr; + QAction *_openAction = nullptr; + QAction *_recentFilesPlaceholderAction = nullptr; + QList _recentFileActions; + QAction *_texturePathsAction = nullptr; + QAction *_autoExpandTreeAction = nullptr; + QAction *_loadSettingsAction = nullptr; + QAction *_saveSettingsAction = nullptr; + QAction *_enableGammaAction = nullptr; + QAction *_mungeSortAction = nullptr; + QAction *_exportAggregateAction = nullptr; + QAction *_exportEmitterAction = nullptr; + QAction *_exportLodAction = nullptr; + QAction *_exportPrimitiveAction = nullptr; + QAction *_exportSoundObjectAction = nullptr; + QAction *_editSoundObjectAction = nullptr; + QAction *_editEmitterAction = nullptr; + QAction *_scaleEmitterAction = nullptr; + QAction *_editPrimitiveAction = nullptr; + QAction *_listMissingTexturesAction = nullptr; + QAction *_copyAssetsAction = nullptr; + QAction *_addToLineupAction = nullptr; + QAction *_aboutAction = nullptr; + QAction *_specialMenuAction = nullptr; + QAction *_objectMenuAction = nullptr; + QAction *_wireframeAction = nullptr; + QAction *_sortingAction = nullptr; + QAction *_restrictAnimsAction = nullptr; + QAction *_statusBarAction = nullptr; + QAction *_fogAction = nullptr; + QAction *_gammaAction = nullptr; + QAction *_invertBackfaceCullingAction = nullptr; + QAction *_backgroundObjectAction = nullptr; + QAction *_captureScreenshotAction = nullptr; + QAction *_makeMovieAction = nullptr; + QAction *_slideshowPrevAction = nullptr; + QAction *_slideshowNextAction = nullptr; + QAction *_objectRotateXAction = nullptr; + QAction *_objectRotateYAction = nullptr; + QAction *_objectRotateZAction = nullptr; + QAction *_objectResetAction = nullptr; + QAction *_objectAlternateAction = nullptr; + QAction *_objectPropertiesAction = nullptr; + QAction *_animationPlayAction = nullptr; + QAction *_animationPauseAction = nullptr; + QAction *_animationStopAction = nullptr; + QAction *_animationStepBackAction = nullptr; + QAction *_animationStepForwardAction = nullptr; + QAction *_lodRecordAction = nullptr; + QAction *_lodIncludeNullAction = nullptr; + QAction *_lodPrevAction = nullptr; + QAction *_lodNextAction = nullptr; + QAction *_lodAutoSwitchAction = nullptr; + QAction *_aggregateBindSubobjectAction = nullptr; + QAction *_cameraFrontAction = nullptr; + QAction *_cameraBackAction = nullptr; + QAction *_cameraLeftAction = nullptr; + QAction *_cameraRightAction = nullptr; + QAction *_cameraTopAction = nullptr; + QAction *_cameraBottomAction = nullptr; + QAction *_cameraRotateXAction = nullptr; + QAction *_cameraRotateYAction = nullptr; + QAction *_cameraRotateZAction = nullptr; + QAction *_cameraCopyScreenAction = nullptr; + QAction *_cameraAnimateAction = nullptr; + QAction *_cameraResetOnDisplayAction = nullptr; + QAction *_cameraResetAction = nullptr; + QAction *_cameraBonePosXAction = nullptr; + QAction *_cameraSettingsAction = nullptr; + QAction *_cameraDistanceAction = nullptr; + QActionGroup *_npatchesGroup = nullptr; + QAction *_npatchesGapAction = nullptr; + QAction *_lightRotateYAction = nullptr; + QAction *_lightRotateZAction = nullptr; + QAction *_exposePrelitAction = nullptr; + QActionGroup *_prelitGroup = nullptr; + QAction *_prelitVertexAction = nullptr; + QAction *_prelitMultipassAction = nullptr; + QAction *_prelitMultitexAction = nullptr; + QString _lastOpenedPath; + QStringList _loadedFiles; + QString _texturePath1; + QString _texturePath2; + bool _restrictAnims = true; + bool _sortingEnabled = true; + bool _animateCamera = false; + bool _autoResetCamera = true; + bool _selectionIsAnimation = false; + bool _showAnimationToolbar = true; + bool _changingAnimationToolbarForSelection = false; + bool _autoExpandAssetTree = true; +}; diff --git a/Code/Tools/W3DViewQt/MainWindow.ui b/Code/Tools/W3DViewQt/MainWindow.ui new file mode 100644 index 000000000..405fe32cf --- /dev/null +++ b/Code/Tools/W3DViewQt/MainWindow.ui @@ -0,0 +1,1105 @@ + + + W3DViewMainWindow + + + + 0 + 0 + 1200 + 800 + + + + W3DViewQt + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Orientation::Horizontal + + + false + + + + + + + + + + + 0 + 0 + 1200 + 22 + + + + + &File + + + + Ex&port... + + + + + + + + + + + + + + + + + + + + + + + + + + + + &Settings + + + + + + + &View + + + + &Toolbars + + + + + + + + N-Patches Subdivision Level + + + + + + + + + + + + + + + + + + + + + &Object + + + + + + + + + + + + + + + + &Emitters + + + + E&dit + + + + + + + + + + + &Primitives + + + + + + + + + &Sound + + + + + + + + Ligh&ting + + + + + + + + + + + + + + + + + + + + + + &Camera + + + + + + + + + + + + + + + + + + + + + + + + + &Background + + + + + + + + + + &Movie + + + + + + + &Help + + + + + + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + Main + + + TopToolBarArea + + + false + + + + + + + + + + + + + + + + + + + + + Object + + + TopToolBarArea + + + false + + + + + + + + + false + + + Animation + + + TopToolBarArea + + + false + + + + + + + + + + &New + + + Ctrl+N + + + + + &Open... + + + Ctrl+O + + + + + true + + + &Munge Sort on Load + + + + + true + + + &Enable Gamma Correction + + + + + &Save Settings... + + + Ctrl+S + + + + + Load &Settings... + + + + + false + + + Recent File + + + + + false + + + &Import Facial Anims... + + + + + false + + + &Aggregate... + + + + + false + + + &Emitter... + + + + + false + + + &LOD... + + + + + false + + + &Primitive... + + + + + false + + + &Sound Object... + + + + + &Texture Path... + + + + + &Animated Sound Options... + + + + + E&xit + + + + + &Texture Paths... + + + + + true + + + &Auto Expand Asset Tree + + + + + true + + + true + + + &Main + + + + + true + + + true + + + Object + + + + + true + + + Animation + + + + + true + + + true + + + &Status Bar + + + + + &Prev + + + PgUp + + + + + &Next + + + PgDown + + + + + true + + + &Wireframe Mode + + + + + true + + + Polygon Sorting + + + Ctrl+P + + + + + true + + + Invert Backface Culling + + + + + Set &Gamma + + + + + Change &Resolution... + + + Change the viewing resolution + + + + + true + + + N-Patches Gap Filling + + + + + true + + + Rotate &X + + + Ctrl+X + + + + + true + + + Rotate &Y + + + + + true + + + + :/w3dview/rotate-z.bmp + :/w3dview/rotate-z-selected.bmp:/w3dview/rotate-z.bmp + + + Rotate &Z + + + + + false + + + &Properties... + + + Return + + + + + true + + + &Restrict Anims + + + + + &Reset + + + + + Toggle Alternate Materials + + + + + &Create Emitter... + + + + + false + + + &Scale Emitter... + + + + + false + + + &Edit Emitter + + + + + Create &Sphere... + + + + + Create &Ring... + + + + + false + + + &Edit Primitive... + + + + + &Create Sound Object... + + + + + false + + + &Edit Sound Object... + + + + + true + + + Rotate &Y + + + Ctrl+Up + + + + + true + + + Rotate &Z + + + Ctrl+Right + + + + + &Ambient... + + + + + &Scene Light... + + + + + &Inc Ambient Intensity + + + + + &Dec Ambient Intensity + + + - + + + + + Inc Scene &Light Intensity + + + + + De&c Scene Light Intensity + + + Ctrl+- + + + + + true + + + Expose Precalculated Lighting + + + + + Kill Scene Light + + + Ctrl+* + + + + + true + + + &Vertex Lighting + + + + + true + + + Multi-&Pass Lighting + + + + + true + + + Multi-Te&xture Lighting + + + + + &Front + + + Ctrl+F + + + + + &Back + + + Ctrl+B + + + + + &Left + + + Ctrl+L + + + + + &Right + + + Ctrl+R + + + + + &Top + + + Ctrl+T + + + + + Bo&ttom + + + Ctrl+M + + + + + true + + + + :/w3dview/y-direction.bmp + :/w3dview/y-direction-selected.bmp:/w3dview/y-direction.bmp + + + Rotate &X Only + + + + + true + + + + :/w3dview/x-direction.bmp + :/w3dview/x-direction-selected.bmp:/w3dview/x-direction.bmp + + + Rotate &Y Only + + + + + true + + + + :/w3dview/z-direction.bmp + :/w3dview/z-direction-selected.bmp:/w3dview/z-direction.bmp + + + Rotate &Z Only + + + + + &Copy Screen Size To Clipboard + + + Ctrl+C + + + + + true + + + &Animate Camera + + + F8 + + + + + true + + + +X Camera + + + + + Settin&gs... + + + + + &Set Distance... + + + Ctrl+D + + + + + true + + + Reset on &Display + + + + + R&eset + + + + + &Color... + + + + + &Bitmap... + + + + + &Object... + + + + + true + + + Fog + + + Ctrl+Alt+F + + + + + false + + + &Make Movie... + + + + + &Capture Screen Shot... + + + F7 + + + + + &About... + + + + + List Missing Textures + + + + + false + + + Copy Asset Files... + + + + + false + + + Add To Lineup... + + + + + true + + + + :/w3dview/play.bmp + :/w3dview/play-selected.bmp:/w3dview/play.bmp + + + &Play + + + + + + :/w3dview/stop.bmp + :/w3dview/stop-selected.bmp:/w3dview/stop.bmp + + + &Stop + + + + + true + + + + :/w3dview/pause.bmp + :/w3dview/pause-selected.bmp:/w3dview/pause.bmp + + + P&ause + + + + + + :/w3dview/reverse.bmp + :/w3dview/reverse-selected.bmp:/w3dview/reverse.bmp + + + Step &Back + + + + + + :/w3dview/ffwd.bmp + :/w3dview/ffwd-selected.bmp:/w3dview/ffwd.bmp + + + Step &Forward + + + + + + W3DViewport + QWidget +
W3DViewport.h
+ 1 +
+
+ + + + +
diff --git a/Code/Tools/W3DViewQt/MeshPropertiesDialog.cpp b/Code/Tools/W3DViewQt/MeshPropertiesDialog.cpp new file mode 100644 index 000000000..a7f14d5c4 --- /dev/null +++ b/Code/Tools/W3DViewQt/MeshPropertiesDialog.cpp @@ -0,0 +1,107 @@ +#include "MeshPropertiesDialog.h" + +#include "ui_MeshPropertiesDialog.h" + +#include "assetmgr.h" +#include "mesh.h" +#include "meshmdl.h" +#include "rendobj.h" +#include "w3d_file.h" + +#include + +MeshPropertiesDialog::MeshPropertiesDialog(const QString &meshName, QWidget *parent) + : QDialog(parent) + , _ui(new Ui::MeshPropertiesDialog) +{ + _ui->setupUi(this); + connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + + if (meshName.isEmpty()) { + setErrorState("No mesh selected."); + return; + } + + _ui->descriptionLabel->setText(QString("Mesh: %1").arg(meshName)); + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + setErrorState("WW3D asset manager is not available."); + return; + } + + const QByteArray name_bytes = meshName.toLatin1(); + RenderObjClass *render_obj = asset_manager->Create_Render_Obj(name_bytes.constData()); + if (!render_obj) { + setErrorState("Failed to load mesh."); + return; + } + + _ui->polygonCountValue->setText(QString::number(render_obj->Get_Num_Polys())); + + if (render_obj->Class_ID() != RenderObjClass::CLASSID_MESH) { + setErrorState("Selected object is not a mesh."); + render_obj->Release_Ref(); + return; + } + + auto *mesh = static_cast(render_obj); + MeshModelClass *model = mesh->Get_Model(); + if (model) { + _ui->vertexCountValue->setText(QString::number(model->Get_Vertex_Count())); + } + + const char *user_text = mesh->Get_User_Text(); + if (user_text) { + _ui->userTextValue->setText(QString::fromLatin1(user_text)); + } else { + _ui->userTextValue->setText(""); + } + + const uint32 flags = mesh->Get_W3D_Flags(); + + if ((flags & W3D_MESH_FLAG_COLLISION_BOX) == W3D_MESH_FLAG_COLLISION_BOX) { + _ui->meshTypeCollision->setChecked(true); + } else if ((flags & W3D_MESH_FLAG_SKIN) == W3D_MESH_FLAG_SKIN) { + _ui->meshTypeSkin->setChecked(true); + } else if ((flags & W3D_MESH_FLAG_SHADOW) == W3D_MESH_FLAG_SHADOW) { + _ui->meshTypeShadow->setChecked(true); + } else { + _ui->meshTypeNormal->setChecked(true); + } + + const uint32 collision_flags = flags & W3D_MESH_FLAG_COLLISION_TYPE_MASK; + if ((collision_flags & W3D_MESH_FLAG_COLLISION_TYPE_PHYSICAL) == W3D_MESH_FLAG_COLLISION_TYPE_PHYSICAL) { + _ui->collisionPhysical->setChecked(true); + } + if ((collision_flags & W3D_MESH_FLAG_COLLISION_TYPE_PROJECTILE) == + W3D_MESH_FLAG_COLLISION_TYPE_PROJECTILE) { + _ui->collisionProjectile->setChecked(true); + } + + if ((flags & W3D_MESH_FLAG_HIDDEN) == W3D_MESH_FLAG_HIDDEN) { + _ui->hiddenCheck->setChecked(true); + } + + render_obj->Release_Ref(); +} + +MeshPropertiesDialog::~MeshPropertiesDialog() +{ + delete _ui; +} + +void MeshPropertiesDialog::setErrorState(const QString &message) +{ + _ui->descriptionLabel->setText(message); + _ui->polygonCountValue->setText("n/a"); + _ui->vertexCountValue->setText("n/a"); + _ui->userTextValue->setText(""); + _ui->meshTypeNormal->setChecked(false); + _ui->meshTypeCollision->setChecked(false); + _ui->meshTypeSkin->setChecked(false); + _ui->meshTypeShadow->setChecked(false); + _ui->collisionPhysical->setChecked(false); + _ui->collisionProjectile->setChecked(false); + _ui->hiddenCheck->setChecked(false); +} diff --git a/Code/Tools/W3DViewQt/MeshPropertiesDialog.h b/Code/Tools/W3DViewQt/MeshPropertiesDialog.h new file mode 100644 index 000000000..ced921a2e --- /dev/null +++ b/Code/Tools/W3DViewQt/MeshPropertiesDialog.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +namespace Ui { +class MeshPropertiesDialog; +} + +class MeshPropertiesDialog final : public QDialog +{ + Q_OBJECT + +public: + explicit MeshPropertiesDialog(const QString &meshName, QWidget *parent = nullptr); + ~MeshPropertiesDialog() override; + +private: + void setErrorState(const QString &message); + + Ui::MeshPropertiesDialog *_ui = nullptr; +}; diff --git a/Code/Tools/W3DViewQt/MeshPropertiesDialog.ui b/Code/Tools/W3DViewQt/MeshPropertiesDialog.ui new file mode 100644 index 000000000..b2aa82c18 --- /dev/null +++ b/Code/Tools/W3DViewQt/MeshPropertiesDialog.ui @@ -0,0 +1,179 @@ + + + MeshPropertiesDialog + + + Mesh Properties + + + + + + + + + Qt::TextSelectableByMouse + + + true + + + + + + + + + Polygon Count: + + + + + + + n/a + + + Qt::TextSelectableByMouse + + + + + + + Vertex Count: + + + + + + + n/a + + + Qt::TextSelectableByMouse + + + + + + + User Text: + + + + + + + n/a + + + Qt::TextSelectableByMouse + + + true + + + + + + + + + Mesh Type + + + + + + false + + + Collision Box + + + + + + + false + + + Skin + + + + + + + false + + + Shadow + + + + + + + false + + + Normal + + + + + + + + + + Collision Type + + + + + + false + + + Physical + + + + + + + false + + + Projectile + + + + + + + + + + false + + + Hidden + + + + + + + QDialogButtonBox::Close + + + + + + + + diff --git a/Code/Tools/W3DViewQt/OpacityVectorEditDialog.cpp b/Code/Tools/W3DViewQt/OpacityVectorEditDialog.cpp new file mode 100644 index 000000000..7023035c5 --- /dev/null +++ b/Code/Tools/W3DViewQt/OpacityVectorEditDialog.cpp @@ -0,0 +1,126 @@ +#include "OpacityVectorEditDialog.h" + +#include "ui_OpacityVectorEditDialog.h" + +#include "euler.h" +#include "matrix3.h" +#include "quat.h" +#include "wwmath.h" + +#include +#include +#include +#include +#include +#include + +namespace { +constexpr float kDegToRad = 3.14159265358979323846f / 180.0f; +constexpr float kRadToDeg = 180.0f / 3.14159265358979323846f; + +void QuaternionToAngles(const Quaternion &quat, float &y_deg, float &z_deg) +{ + Matrix3D rotation = Build_Matrix3D(quat); + EulerAnglesClass euler(rotation, EulerOrderXYZr); + y_deg = static_cast(euler.Get_Angle(1) * kRadToDeg); + z_deg = static_cast(euler.Get_Angle(2) * kRadToDeg); + y_deg = static_cast(WWMath::Wrap(y_deg, 0.0f, 360.0f)); + z_deg = static_cast(WWMath::Wrap(z_deg, 0.0f, 360.0f)); +} +} + +OpacityVectorEditDialog::OpacityVectorEditDialog(const AlphaVectorStruct &value, QWidget *parent) + : QDialog(parent), + _ui(new Ui::OpacityVectorEditDialog), + _value(value) +{ + _ui->setupUi(this); + + connect(_ui->buttonBox, &QDialogButtonBox::accepted, this, &OpacityVectorEditDialog::accept); + connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &OpacityVectorEditDialog::reject); + connect(_ui->intensitySlider, &QSlider::valueChanged, this, + &OpacityVectorEditDialog::handleIntensitySlider); + connect(_ui->intensitySpin, qOverload(&QDoubleSpinBox::valueChanged), this, + &OpacityVectorEditDialog::handleIntensitySpin); + connect(_ui->angleYSpin, qOverload(&QSpinBox::valueChanged), this, + &OpacityVectorEditDialog::handleAngleChanged); + connect(_ui->angleZSpin, qOverload(&QSpinBox::valueChanged), this, + &OpacityVectorEditDialog::handleAngleChanged); + + syncFromValue(); +} + +OpacityVectorEditDialog::~OpacityVectorEditDialog() +{ + delete _ui; +} + +AlphaVectorStruct OpacityVectorEditDialog::value() const +{ + return _value; +} + +void OpacityVectorEditDialog::handleIntensitySlider(int value) +{ + const float position = static_cast(value) / 10.0f; + const float intensity = intensityFromSliderPosition(position); + _ui->intensitySpin->blockSignals(true); + _ui->intensitySpin->setValue(intensity); + _ui->intensitySpin->blockSignals(false); + updateValueFromControls(); +} + +void OpacityVectorEditDialog::handleIntensitySpin(double value) +{ + const float position = sliderPositionFromIntensity(static_cast(value)); + _ui->intensitySlider->blockSignals(true); + _ui->intensitySlider->setValue(static_cast(position * 10.0f)); + _ui->intensitySlider->blockSignals(false); + updateValueFromControls(); +} + +void OpacityVectorEditDialog::handleAngleChanged() +{ + updateValueFromControls(); +} + +void OpacityVectorEditDialog::syncFromValue() +{ + float y_deg = 0.0f; + float z_deg = 0.0f; + QuaternionToAngles(_value.angle, y_deg, z_deg); + + _ui->angleYSpin->setValue(static_cast(std::clamp(y_deg, 0.0f, 179.0f))); + _ui->angleZSpin->setValue(static_cast(std::clamp(z_deg, 0.0f, 179.0f))); + _ui->intensitySpin->setValue(_value.intensity); + + const float position = sliderPositionFromIntensity(_value.intensity); + _ui->intensitySlider->setValue(static_cast(position * 10.0f)); +} + +float OpacityVectorEditDialog::sliderPositionFromIntensity(float intensity) const +{ + const float percent = std::clamp(intensity / 10.0f, 0.0f, 1.0f); + const float pos = std::atan(percent * 11.0f) / (84.5f * kDegToRad) * 10.0f; + return std::clamp(pos, 0.0f, 10.0f); +} + +float OpacityVectorEditDialog::intensityFromSliderPosition(float position) const +{ + const float percent = std::tan((position / 10.0f) * 84.5f * kDegToRad) / 11.0f; + return 10.0f * std::clamp(percent, 0.0f, 1.0f); +} + +void OpacityVectorEditDialog::updateValueFromControls() +{ + const float intensity = static_cast(_ui->intensitySpin->value()); + const float y_deg = static_cast(_ui->angleYSpin->value()); + const float z_deg = static_cast(_ui->angleZSpin->value()); + + Matrix3 rot_mat(true); + rot_mat.Rotate_Y(y_deg * kDegToRad); + rot_mat.Rotate_Z(z_deg * kDegToRad); + + _value.angle = Build_Quaternion(rot_mat); + _value.intensity = intensity; +} diff --git a/Code/Tools/W3DViewQt/OpacityVectorEditDialog.h b/Code/Tools/W3DViewQt/OpacityVectorEditDialog.h new file mode 100644 index 000000000..ba3874b27 --- /dev/null +++ b/Code/Tools/W3DViewQt/OpacityVectorEditDialog.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +#include "sphereobj.h" + +namespace Ui { +class OpacityVectorEditDialog; +} + +class OpacityVectorEditDialog final : public QDialog +{ + Q_OBJECT + +public: + explicit OpacityVectorEditDialog(const AlphaVectorStruct &value, QWidget *parent = nullptr); + ~OpacityVectorEditDialog() override; + + AlphaVectorStruct value() const; + +private slots: + void handleIntensitySlider(int value); + void handleIntensitySpin(double value); + void handleAngleChanged(); + +private: + void syncFromValue(); + float sliderPositionFromIntensity(float intensity) const; + float intensityFromSliderPosition(float position) const; + void updateValueFromControls(); + + Ui::OpacityVectorEditDialog *_ui = nullptr; + AlphaVectorStruct _value; +}; diff --git a/Code/Tools/W3DViewQt/OpacityVectorEditDialog.ui b/Code/Tools/W3DViewQt/OpacityVectorEditDialog.ui new file mode 100644 index 000000000..30e0ae7f8 --- /dev/null +++ b/Code/Tools/W3DViewQt/OpacityVectorEditDialog.ui @@ -0,0 +1,132 @@ + + + OpacityVectorEditDialog + + + Opacity Vector + + + + + + + + Intensity: + + + intensitySlider + + + + + + + 100 + + + Qt::Horizontal + + + + + + + Intensity Value: + + + intensitySpin + + + + + + + 2 + + + 10.000000000000000 + + + + + + + Angles: + + + angleYSpin + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Y + + + angleYSpin + + + + + + + deg + + + 179 + + + + + + + Z + + + angleZSpin + + + + + + + deg + + + 179 + + + + + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + diff --git a/Code/Tools/W3DViewQt/PlaySoundDialog.cpp b/Code/Tools/W3DViewQt/PlaySoundDialog.cpp new file mode 100644 index 000000000..6b9ca76b8 --- /dev/null +++ b/Code/Tools/W3DViewQt/PlaySoundDialog.cpp @@ -0,0 +1,85 @@ +#include "PlaySoundDialog.h" + +#include "ui_PlaySoundDialog.h" + +#include "AudibleSound.h" +#include "WWAudio.h" + +#include +#include +#include +#include +#include + +PlaySoundDialog::PlaySoundDialog(const QString &filename, QWidget *parent) + : QDialog(parent) + , _ui(new Ui::PlaySoundDialog) + , _filename(filename) +{ + _ui->setupUi(this); + _ui->soundFileLabel->setText(QString("Sound file: %1").arg(_filename)); + + connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + connect(_ui->playButton, &QPushButton::clicked, this, &PlaySoundDialog::playSound); + connect(_ui->stopButton, &QPushButton::clicked, this, &PlaySoundDialog::stopSound); + + createSound(); +} + +PlaySoundDialog::~PlaySoundDialog() +{ + stopSound(); + if (_sound) { + _sound->Release_Ref(); + _sound = nullptr; + } + delete _ui; +} + +bool PlaySoundDialog::isReady() const +{ + return _sound != nullptr; +} + +bool PlaySoundDialog::createSound() +{ + const QString filename = _filename.trimmed(); + if (filename.isEmpty()) { + QMessageBox::warning(this, "Play Sound", "No sound file specified."); + return false; + } + + auto *audio = WWAudioClass::Get_Instance(); + if (!audio) { + QMessageBox::warning(this, "Play Sound", "Audio system is not available."); + return false; + } + + // Keep an explicitly selected path intact for preview. QFile::encodeName + // provides the narrow, native filename representation required by WWAudio. + const QString native_filename = QDir::toNativeSeparators(filename); + const QByteArray filename_bytes = QFile::encodeName(native_filename); + _sound = audio->Create_Sound_Effect(filename_bytes.constData()); + if (!_sound) { + QMessageBox::warning(this, "Play Sound", QString("Cannot find sound file: %1").arg(filename)); + return false; + } + + playSound(); + return true; +} + +void PlaySoundDialog::playSound() +{ + if (_sound) { + _sound->Stop(); + _sound->Play(); + } +} + +void PlaySoundDialog::stopSound() +{ + if (_sound) { + _sound->Stop(); + } +} diff --git a/Code/Tools/W3DViewQt/PlaySoundDialog.h b/Code/Tools/W3DViewQt/PlaySoundDialog.h new file mode 100644 index 000000000..b8086bd08 --- /dev/null +++ b/Code/Tools/W3DViewQt/PlaySoundDialog.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include + +class AudibleSoundClass; + +namespace Ui { +class PlaySoundDialog; +} + +class PlaySoundDialog final : public QDialog +{ + Q_OBJECT + +public: + explicit PlaySoundDialog(const QString &filename, QWidget *parent = nullptr); + ~PlaySoundDialog() override; + + bool isReady() const; + +private slots: + void playSound(); + void stopSound(); + +private: + bool createSound(); + + Ui::PlaySoundDialog *_ui = nullptr; + QString _filename; + AudibleSoundClass *_sound = nullptr; +}; diff --git a/Code/Tools/W3DViewQt/PlaySoundDialog.ui b/Code/Tools/W3DViewQt/PlaySoundDialog.ui new file mode 100644 index 000000000..f6c8eeaa2 --- /dev/null +++ b/Code/Tools/W3DViewQt/PlaySoundDialog.ui @@ -0,0 +1,45 @@ + + + PlaySoundDialog + + + Play Sound + + + + + + + + + + + + + + + Play + + + + + + + Stop + + + + + + + + + QDialogButtonBox::Close + + + + + + + + diff --git a/Code/Tools/W3DViewQt/README.md b/Code/Tools/W3DViewQt/README.md new file mode 100644 index 000000000..2999c3cf0 --- /dev/null +++ b/Code/Tools/W3DViewQt/README.md @@ -0,0 +1,36 @@ +# W3DViewQt + +W3DViewQt is the Qt Widgets port of the legacy MFC W3D Viewer. Widget hierarchy, labels, layouts, static menus, actions, toolbars, and tab order live in the checked-in `.ui` files in this directory. Selection-specific and data-driven menu entries remain in C++, along with engine behavior, validation, and signal handling. + +## Editing layouts + +Open a `.ui` file with Qt Designer, save it in place, and rebuild the target. Do not edit generated `ui_*.h` files; CMake AUTOUIC regenerates them in the build tree. `MainWindow.ui` promotes its central viewport widget to `W3DViewport`, whose Direct3D implementation intentionally remains in C++. + +When adding a form, list it in `W3DVIEW_QT_UI` in `CMakeLists.txt` and add its filename to `tests/VerifyDesignerForms.cmake`. The Designer-form test checks that every expected form is tracked and accepted by the selected Qt `uic`. + +## Build and test + +From the repository root in a Visual Studio 2022 x64 developer environment: + +```powershell +$env:VCPKG_ROOT = 'C:\path\to\vcpkg' +cmake --preset windows-qt-tools -B build/w3dview-qt +cmake --build build/w3dview-qt --config Release --target w3dview_qt wwaudio_openal_tests w3dview_qt_main_window_tests w3dview_qt_settings_save_mask_tests w3dview_qt_scene_light_tests w3dview_qt_emitter_edit_tests w3dview_qt_primitive_shader_tests w3dview_qt_background_object_dialog_tests w3dview_qt_sound_dialog_tests w3dview_qt_resolution_dialog_tests w3dview_qt_export_directory_dialog_tests w3dview_qt_export_utils_tests ww3d2_framegrab_tests ww3d2_screenshot_api_tests wwlib_mempool_tests +ctest --test-dir build/w3dview-qt -C Release --output-on-failure +``` + +The preset uses `C:\vcpkg.installed` for installed packages; override +`VCPKG_INSTALLED_DIR` when configuring if your package tree lives elsewhere. +The `windows-qt-tools` preset enables FFmpeg and OpenAL Soft for the x64 viewer +and disables Miles. +The OpenAL tests select OpenAL Soft's null output driver automatically, so the +default CTest run does not require speakers. The configure presets available in +a checkout are listed by `cmake --list-presets`. + +Building `w3dview_qt` runs `windeployqt` after linking. Launch the executable +directly from its configuration directory and keep the generated plugin folders, +especially `platforms/qwindows.dll`, beside it when copying or packaging the +viewer. Windows systems also need the legacy DirectX June 2010 runtime that +provides `d3dx9_43.dll`; it is not deployed by `windeployqt`. + +Port status, validation evidence, and remaining manual checks are recorded in `W3DViewQt-ExecPlan.md` at the repository root. diff --git a/Code/Tools/W3DViewQt/RenderObjUtils.cpp b/Code/Tools/W3DViewQt/RenderObjUtils.cpp new file mode 100644 index 000000000..5de3401ee --- /dev/null +++ b/Code/Tools/W3DViewQt/RenderObjUtils.cpp @@ -0,0 +1,268 @@ +#include "RenderObjUtils.h" + +#include "agg_def.h" +#include "assetmgr.h" +#include "hlod.h" +#include "part_emt.h" +#include "rendobj.h" +#include "ringobj.h" +#include "soundrobj.h" +#include "sphereobj.h" + +#include +#include + +#include + +void UpdateLodPrototype(HLodClass &hlod) +{ + auto *definition = new HLodDefClass(hlod); + auto *prototype = new HLodPrototypeClass(definition); + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + delete prototype; + return; + } + + asset_manager->Remove_Prototype(definition->Get_Name()); + asset_manager->Add_Prototype(prototype); +} + +void UpdateAggregatePrototype(RenderObjClass &render_obj) +{ + auto *definition = new AggregateDefClass(render_obj); + auto *prototype = new AggregatePrototypeClass(definition); + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + delete prototype; + return; + } + + asset_manager->Remove_Prototype(definition->Get_Name()); + asset_manager->Add_Prototype(prototype); +} + +bool RenameAggregatePrototype(const char *old_name, const char *new_name) +{ + if (!old_name || !new_name) { + return false; + } + + const QString old_text = QString::fromLatin1(old_name); + const QString new_text = QString::fromLatin1(new_name); + if (old_text.compare(new_text, Qt::CaseInsensitive) == 0) { + return false; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + return false; + } + + auto *proto = static_cast(asset_manager->Find_Prototype(old_name)); + if (!proto) { + return false; + } + + AggregateDefClass *definition = proto->Get_Definition(); + if (!definition) { + return false; + } + + AggregateDefClass *new_definition = definition->Clone(); + asset_manager->Remove_Prototype(old_name); + + new_definition->Set_Name(new_name); + auto *new_proto = new AggregatePrototypeClass(new_definition); + asset_manager->Add_Prototype(new_proto); + return true; +} + +bool UpdateSpherePrototype(SphereRenderObjClass &sphere, + const QString ®istered_name, + QString *error_message) +{ + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + if (error_message) { + *error_message = "WW3D asset manager is not available."; + } + return false; + } + + const char *object_name = sphere.Get_Name(); + const QString new_name = object_name ? QString::fromLatin1(object_name).trimmed() : QString(); + if (new_name.isEmpty()) { + if (error_message) { + *error_message = "Sphere name is required."; + } + return false; + } + + const QByteArray new_name_bytes = new_name.toLatin1(); + const bool replaces_registered_name = !registered_name.isEmpty() && + registered_name.compare(new_name, Qt::CaseInsensitive) == 0; + if (asset_manager->Find_Prototype(new_name_bytes.constData()) && !replaces_registered_name) { + if (error_message) { + *error_message = QString("An asset named '%1' already exists.").arg(new_name); + } + return false; + } + + auto prototype = std::make_unique(&sphere); + if (!registered_name.isEmpty()) { + const QByteArray registered_name_bytes = registered_name.toLatin1(); + asset_manager->Remove_Prototype(registered_name_bytes.constData()); + } + asset_manager->Add_Prototype(prototype.release()); + return true; +} + +bool UpdateRingPrototype(RingRenderObjClass &ring, + const QString ®istered_name, + QString *error_message) +{ + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + if (error_message) { + *error_message = "WW3D asset manager is not available."; + } + return false; + } + + const char *object_name = ring.Get_Name(); + const QString new_name = object_name ? QString::fromLatin1(object_name).trimmed() : QString(); + if (new_name.isEmpty()) { + if (error_message) { + *error_message = "Ring name is required."; + } + return false; + } + + const QByteArray new_name_bytes = new_name.toLatin1(); + const bool replaces_registered_name = !registered_name.isEmpty() && + registered_name.compare(new_name, Qt::CaseInsensitive) == 0; + if (asset_manager->Find_Prototype(new_name_bytes.constData()) && !replaces_registered_name) { + if (error_message) { + *error_message = QString("An asset named '%1' already exists.").arg(new_name); + } + return false; + } + + auto prototype = std::make_unique(&ring); + if (!registered_name.isEmpty()) { + const QByteArray registered_name_bytes = registered_name.toLatin1(); + asset_manager->Remove_Prototype(registered_name_bytes.constData()); + } + asset_manager->Add_Prototype(prototype.release()); + return true; +} + +bool UpdateSoundPrototype(SoundRenderObjClass &sound, + const QString ®istered_name, + QString *error_message) +{ + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + if (error_message) { + *error_message = "WW3D asset manager is not available."; + } + return false; + } + + const char *object_name = sound.Get_Name(); + const QString new_name = object_name ? QString::fromLatin1(object_name).trimmed() : QString(); + if (new_name.isEmpty()) { + if (error_message) { + *error_message = "Sound object name is required."; + } + return false; + } + + const QByteArray new_name_bytes = new_name.toLatin1(); + const bool replaces_registered_name = !registered_name.isEmpty() + && registered_name.compare(new_name, Qt::CaseInsensitive) == 0; + if (asset_manager->Find_Prototype(new_name_bytes.constData()) && !replaces_registered_name) { + if (error_message) { + *error_message = QString("An asset named '%1' already exists.").arg(new_name); + } + return false; + } + + auto *definition = new SoundRenderObjDefClass(sound); + auto prototype = std::make_unique(definition); + // The prototype retains its own reference to the definition. + definition->Release_Ref(); + + if (!registered_name.isEmpty()) { + const QByteArray registered_name_bytes = registered_name.toLatin1(); + asset_manager->Remove_Prototype(registered_name_bytes.constData()); + } + asset_manager->Add_Prototype(prototype.release()); + return true; +} + +namespace { +bool ContainsName(const QStringList &names, const QString &candidate) +{ + for (const auto &name : names) { + if (name.compare(candidate, Qt::CaseInsensitive) == 0) { + return true; + } + } + return false; +} +} // namespace + +void CollectEmitterNames(RenderObjClass &render_obj, QStringList &names) +{ + const int count = render_obj.Get_Num_Sub_Objects(); + for (int index = 0; index < count; ++index) { + RenderObjClass *sub_obj = render_obj.Get_Sub_Object(index); + if (!sub_obj) { + continue; + } + + if (sub_obj->Class_ID() == RenderObjClass::CLASSID_PARTICLEEMITTER) { + const char *name = sub_obj->Get_Name(); + if (name) { + const QString text = QString::fromLatin1(name); + if (!text.isEmpty() && !ContainsName(names, text)) { + names.push_back(text); + } + } + } + + CollectEmitterNames(*sub_obj, names); + sub_obj->Release_Ref(); + } +} + +int CountParticles(RenderObjClass *render_obj) +{ + if (!render_obj) { + return 0; + } + + int count = 0; + const int sub_count = render_obj->Get_Num_Sub_Objects(); + for (int index = 0; index < sub_count; ++index) { + RenderObjClass *sub_obj = render_obj->Get_Sub_Object(index); + if (sub_obj) { + count += CountParticles(sub_obj); + sub_obj->Release_Ref(); + } + } + + if (render_obj->Class_ID() == RenderObjClass::CLASSID_PARTICLEEMITTER) { + auto *emitter = static_cast(render_obj); + ParticleBufferClass *buffer = emitter->Peek_Buffer(); + if (buffer) { + count += buffer->Get_Particle_Count(); + } + } + + return count; +} diff --git a/Code/Tools/W3DViewQt/RenderObjUtils.h b/Code/Tools/W3DViewQt/RenderObjUtils.h new file mode 100644 index 000000000..7887d53c6 --- /dev/null +++ b/Code/Tools/W3DViewQt/RenderObjUtils.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +class RenderObjClass; +class HLodClass; +class RingRenderObjClass; +class SoundRenderObjClass; +class SphereRenderObjClass; + +void UpdateLodPrototype(HLodClass &hlod); +void UpdateAggregatePrototype(RenderObjClass &render_obj); +bool RenameAggregatePrototype(const char *old_name, const char *new_name); +bool UpdateSpherePrototype(SphereRenderObjClass &sphere, + const QString ®istered_name, + QString *error_message = nullptr); +bool UpdateRingPrototype(RingRenderObjClass &ring, + const QString ®istered_name, + QString *error_message = nullptr); +bool UpdateSoundPrototype(SoundRenderObjClass &sound, + const QString ®istered_name, + QString *error_message = nullptr); +void CollectEmitterNames(RenderObjClass &render_obj, QStringList &names); +int CountParticles(RenderObjClass *render_obj); diff --git a/Code/Tools/W3DViewQt/ResolutionDialog.cpp b/Code/Tools/W3DViewQt/ResolutionDialog.cpp new file mode 100644 index 000000000..880f19a8c --- /dev/null +++ b/Code/Tools/W3DViewQt/ResolutionDialog.cpp @@ -0,0 +1,210 @@ +#include "ResolutionDialog.h" + +#include "ui_ResolutionDialog.h" + +#include "rddesc.h" +#include "ww3d.h" + +#include +#include +#include +#include + +#include + +namespace { +constexpr int kRoleWidth = Qt::UserRole + 1; +constexpr int kRoleHeight = Qt::UserRole + 2; +constexpr int kRoleBpp = Qt::UserRole + 3; + +QVector enumerateModes() +{ + QVector modes; + const RenderDeviceDescClass &device_info = WW3D::Get_Render_Device_Desc(); + const DynamicVectorClass &res_list = device_info.Enumerate_Resolutions(); + modes.reserve(res_list.Count()); + for (int index = 0; index < res_list.Count(); ++index) { + modes.push_back(ResolutionDialog::Mode( + res_list[index].Width, res_list[index].Height, res_list[index].BitDepth)); + } + return modes; +} + +ResolutionDialog::Mode currentMode() +{ + ResolutionDialog::Mode mode; + bool windowed = true; + WW3D::Get_Device_Resolution( + mode.width, mode.height, mode.bitsPerPixel, windowed); + return mode; +} +} // namespace + +ResolutionDialog::ResolutionDialog(const Mode &preferredMode, + bool borderlessFullscreen, + QWidget *parent) + : QDialog(parent) + , _ui(new Ui::ResolutionDialog) + , _currentMode(currentMode()) + , _preferredMode(preferredMode) +{ + initialize(enumerateModes(), borderlessFullscreen); +} + +ResolutionDialog::ResolutionDialog(const QVector &availableModes, + const Mode ¤tMode, + const Mode &preferredMode, + bool borderlessFullscreen, + QWidget *parent) + : QDialog(parent) + , _ui(new Ui::ResolutionDialog) + , _currentMode(currentMode) + , _preferredMode(preferredMode) +{ + initialize(availableModes, borderlessFullscreen); +} + +ResolutionDialog::~ResolutionDialog() +{ + delete _ui; +} + +void ResolutionDialog::initialize(const QVector &availableModes, + bool borderlessFullscreen) +{ + _ui->setupUi(this); + _ui->fullscreenCheck->setChecked(borderlessFullscreen); + + connect(_ui->resolutionTable, + &QTableWidget::cellDoubleClicked, + this, + &ResolutionDialog::onDoubleClicked); + connect(_ui->buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + + QVector modes; + modes.reserve(availableModes.size()); + for (const Mode &mode : availableModes) { + if (mode.isValid() && + (_currentMode.bitsPerPixel <= 0 || + mode.bitsPerPixel == _currentMode.bitsPerPixel)) { + modes.push_back(mode); + } + } + + std::sort(modes.begin(), modes.end(), [](const Mode &left, const Mode &right) { + if (left.width != right.width) { + return left.width < right.width; + } + if (left.height != right.height) { + return left.height < right.height; + } + return left.bitsPerPixel < right.bitsPerPixel; + }); + modes.erase(std::unique(modes.begin(), modes.end()), modes.end()); + + if (modes.isEmpty() && _currentMode.isValid()) { + modes.push_back(_currentMode); + } + + _ui->resolutionTable->setRowCount(0); + const auto append_resolution = [this](int width, int height, int bpp) { + const int row = _ui->resolutionTable->rowCount(); + _ui->resolutionTable->insertRow(row); + + auto *res_item = new QTableWidgetItem(QString("%1 x %2").arg(width).arg(height)); + res_item->setData(kRoleWidth, width); + res_item->setData(kRoleHeight, height); + res_item->setData(kRoleBpp, bpp); + _ui->resolutionTable->setItem(row, 0, res_item); + + const quint64 colors = (bpp >= 0 && bpp < 63) ? (quint64(1) << bpp) : 0; + auto *bpp_item = + new QTableWidgetItem(QString("%1 bpp (%2 colors)").arg(bpp).arg(colors)); + _ui->resolutionTable->setItem(row, 1, bpp_item); + }; + + for (const Mode &mode : modes) { + append_resolution(mode.width, mode.height, mode.bitsPerPixel); + } + + selectDefaultRow(); +} + +int ResolutionDialog::selectedWidth() const +{ + const int row = _ui->resolutionTable->currentRow(); + if (row < 0) { + return 0; + } + + const auto *item = _ui->resolutionTable->item(row, 0); + return item ? item->data(kRoleWidth).toInt() : 0; +} + +int ResolutionDialog::selectedHeight() const +{ + const int row = _ui->resolutionTable->currentRow(); + if (row < 0) { + return 0; + } + + const auto *item = _ui->resolutionTable->item(row, 0); + return item ? item->data(kRoleHeight).toInt() : 0; +} + +int ResolutionDialog::selectedBitsPerPixel() const +{ + const int row = _ui->resolutionTable->currentRow(); + if (row < 0) { + return 0; + } + + const auto *item = _ui->resolutionTable->item(row, 0); + return item ? item->data(kRoleBpp).toInt() : 0; +} + +bool ResolutionDialog::fullscreen() const +{ + return _ui->fullscreenCheck->isChecked(); +} + +void ResolutionDialog::selectDefaultRow() +{ + int current_row = -1; + int preferred_row = -1; + for (int row = 0; row < _ui->resolutionTable->rowCount(); ++row) { + const auto *item = _ui->resolutionTable->item(row, 0); + if (!item) { + continue; + } + + const Mode mode(item->data(kRoleWidth).toInt(), + item->data(kRoleHeight).toInt(), + item->data(kRoleBpp).toInt()); + if (preferred_row < 0 && mode == _preferredMode) { + preferred_row = row; + } + if (current_row < 0 && mode == _currentMode) { + current_row = row; + } + } + + const int selected_row = preferred_row >= 0 + ? preferred_row + : (current_row >= 0 ? current_row : 0); + if (selected_row >= 0 && selected_row < _ui->resolutionTable->rowCount()) { + _ui->resolutionTable->setCurrentCell(selected_row, 0); + _ui->resolutionTable->selectRow(selected_row); + } +} + +void ResolutionDialog::onDoubleClicked(int row, int column) +{ + Q_UNUSED(column); + if (row >= 0 && row < _ui->resolutionTable->rowCount()) { + _ui->resolutionTable->setCurrentCell(row, 0); + _ui->resolutionTable->selectRow(row); + accept(); + } +} diff --git a/Code/Tools/W3DViewQt/ResolutionDialog.h b/Code/Tools/W3DViewQt/ResolutionDialog.h new file mode 100644 index 000000000..dc77842d0 --- /dev/null +++ b/Code/Tools/W3DViewQt/ResolutionDialog.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include + +namespace Ui { +class ResolutionDialog; +} + +class ResolutionDialog final : public QDialog +{ + Q_OBJECT + +public: + struct Mode { + Mode() = default; + Mode(int modeWidth, int modeHeight, int modeBitsPerPixel) + : width(modeWidth) + , height(modeHeight) + , bitsPerPixel(modeBitsPerPixel) + { + } + + bool isValid() const + { + return width > 0 && height > 0 && bitsPerPixel > 0; + } + + bool operator==(const Mode &other) const + { + return width == other.width && height == other.height && + bitsPerPixel == other.bitsPerPixel; + } + + int width = 0; + int height = 0; + int bitsPerPixel = 0; + }; + + explicit ResolutionDialog(const Mode &preferredMode, + bool borderlessFullscreen, + QWidget *parent = nullptr); + ResolutionDialog(const QVector &availableModes, + const Mode ¤tMode, + const Mode &preferredMode, + bool borderlessFullscreen, + QWidget *parent = nullptr); + ~ResolutionDialog() override; + + int selectedWidth() const; + int selectedHeight() const; + int selectedBitsPerPixel() const; + bool fullscreen() const; + +private slots: + void onDoubleClicked(int row, int column); + +private: + void initialize(const QVector &availableModes, bool borderlessFullscreen); + void selectDefaultRow(); + + Ui::ResolutionDialog *_ui = nullptr; + Mode _currentMode; + Mode _preferredMode; +}; diff --git a/Code/Tools/W3DViewQt/ResolutionDialog.ui b/Code/Tools/W3DViewQt/ResolutionDialog.ui new file mode 100644 index 000000000..22ecd482a --- /dev/null +++ b/Code/Tools/W3DViewQt/ResolutionDialog.ui @@ -0,0 +1,63 @@ + + + ResolutionDialog + + + Change Resolution + + + + + + Select the render resolution used in borderless fullscreen. In windowed mode, the viewport follows the window size; this selection is saved for the next borderless fullscreen transition. + + + true + + + + + + + QAbstractItemView::NoEditTriggers + + + QAbstractItemView::SingleSelection + + + QAbstractItemView::SelectRows + + + true + + + + Resolution + + + + + Bit Depth + + + + + + + + &Borderless fullscreen + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + diff --git a/Code/Tools/W3DViewQt/RingEditDialog.cpp b/Code/Tools/W3DViewQt/RingEditDialog.cpp new file mode 100644 index 000000000..6cc46a499 --- /dev/null +++ b/Code/Tools/W3DViewQt/RingEditDialog.cpp @@ -0,0 +1,744 @@ +#include "RingEditDialog.h" + +#include "ui_RingEditDialog.h" + +#include "KeyframeTableUtils.h" + +#include "assetmgr.h" +#include "ringobj.h" +#include "shader.h" +#include "texture.h" +#include "vector2.h" +#include "vector3.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +struct ShaderPreset { + const char *label; + ShaderClass shader; +}; + +ShaderPreset BuildPreset(const char *label, const ShaderClass &shader) +{ + ShaderPreset preset{label, shader}; + return preset; +} + +const ShaderPreset *ShaderPresets(int &count) +{ + static ShaderPreset presets[] = { + BuildPreset("Additive", ShaderClass::_PresetAdditiveShader), + BuildPreset("Alpha", ShaderClass::_PresetAlphaShader), + BuildPreset("Opaque", ShaderClass::_PresetOpaqueShader), + BuildPreset("Multiplicative", ShaderClass::_PresetMultiplicativeShader), + }; + + count = static_cast(sizeof(presets) / sizeof(presets[0])); + return presets; +} + +bool ShaderMatches(const ShaderClass &a, const ShaderClass &b) +{ + return a.Get_Bits() == b.Get_Bits(); +} + +void ConfigureKeyframeTable(QTableWidget *table) +{ + table->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); + table->setSelectionBehavior(QAbstractItemView::SelectRows); + table->setSelectionMode(QAbstractItemView::ExtendedSelection); + table->setEditTriggers(QAbstractItemView::NoEditTriggers); + table->setSortingEnabled(false); + table->setShowGrid(true); +} + +QVector> SortedRows(const QTableWidget *table) +{ + QVector> rows = GetKeyframeRows(table); + std::sort(rows.begin(), rows.end(), [](const QVector &a, const QVector &b) { + const double time_a = a.isEmpty() ? 0.0 : a[0]; + const double time_b = b.isEmpty() ? 0.0 : b[0]; + return time_a < time_b; + }); + return rows; +} + +std::optional PromptKeyTime(QWidget *parent, const QString &title) +{ + bool ok = false; + const double time = QInputDialog::getDouble(parent, title, "Time (0-1):", 0.0, 0.0, 1.0, 3, &ok); + if (!ok) { + return std::nullopt; + } + return time; +} + +RingColorChannelClass BuildColorChannel(QTableWidget *table, const Vector3 &fallback) +{ + RingColorChannelClass channel; + channel.Reset(); + + const QVector> rows = SortedRows(table); + if (rows.isEmpty()) { + channel.Add_Key(fallback, 0.0f); + return channel; + } + + for (const QVector &row : rows) { + if (row.size() < 4) { + continue; + } + channel.Add_Key(Vector3(row[1], row[2], row[3]), static_cast(row[0])); + } + + return channel; +} + +RingAlphaChannelClass BuildAlphaChannel(QTableWidget *table, float fallback) +{ + RingAlphaChannelClass channel; + channel.Reset(); + + const QVector> rows = SortedRows(table); + if (rows.isEmpty()) { + channel.Add_Key(fallback, 0.0f); + return channel; + } + + for (const QVector &row : rows) { + if (row.size() < 2) { + continue; + } + channel.Add_Key(static_cast(row[1]), static_cast(row[0])); + } + + return channel; +} + +RingScaleChannelClass BuildScaleChannel(QTableWidget *table, const Vector2 &fallback) +{ + RingScaleChannelClass channel; + channel.Reset(); + + const QVector> rows = SortedRows(table); + if (rows.isEmpty()) { + channel.Add_Key(fallback, 0.0f); + return channel; + } + + for (const QVector &row : rows) { + if (row.size() < 3) { + continue; + } + channel.Add_Key(Vector2(row[1], row[2]), static_cast(row[0])); + } + + return channel; +} +} + +RingEditDialog::RingEditDialog(RingRenderObjClass *ring, QWidget *parent) + : QDialog(parent), + _ui(new Ui::RingEditDialog) +{ + const bool is_new_ring = ring == nullptr; + _ui->setupUi(this); + + _nameEdit = _ui->nameEdit; + _textureEdit = _ui->textureEdit; + _lifetimeSpin = _ui->lifetimeSpin; + _shaderCombo = _ui->shaderCombo; + _cameraAlignCheck = _ui->cameraAlignCheck; + _loopCheck = _ui->loopCheck; + _tilingSpin = _ui->tilingSpin; + _colorKeysTable = _ui->colorKeysTable; + _alphaKeysTable = _ui->alphaKeysTable; + _innerXSpin = _ui->innerXSpin; + _innerYSpin = _ui->innerYSpin; + _outerXSpin = _ui->outerXSpin; + _outerYSpin = _ui->outerYSpin; + _innerScaleTable = _ui->innerScaleTable; + _outerScaleTable = _ui->outerScaleTable; + + ConfigureKeyframeTable(_colorKeysTable); + ConfigureKeyframeTable(_alphaKeysTable); + ConfigureKeyframeTable(_innerScaleTable); + ConfigureKeyframeTable(_outerScaleTable); + + if (ring) { + _ring = ring; + _ring->Add_Ref(); + } else { + _ring = new RingRenderObjClass; + _ring->Set_Name("Ring"); + } + + if (_ring && _ring->Get_Name()) { + _oldName = QString::fromLatin1(_ring->Get_Name()); + } + _registeredName = is_new_ring ? QString() : _oldName; + _initialApplyRequired = is_new_ring; + if (_ring) { + _lastAppliedRing = new RingRenderObjClass(*_ring); + } + + int preset_count = 0; + const ShaderPreset *presets = ShaderPresets(preset_count); + for (int i = 0; i < preset_count; ++i) { + _shaderCombo->addItem(presets[i].label, i); + } + + const QVector color_specs = { + {0.0, 1.0, 3}, + {0.0, 1.0, 3}, + {0.0, 1.0, 3}, + {0.0, 1.0, 3}, + }; + const QVector alpha_specs = { + {0.0, 1.0, 3}, + {0.0, 1.0, 3}, + }; + const QVector scale_specs = { + {0.0, 1.0, 3}, + {0.0, 10000.0, 3}, + {0.0, 10000.0, 3}, + }; + connect(_ui->browseButton, &QPushButton::clicked, this, &RingEditDialog::browseTexture); + connect(_ui->buttonBox, &QDialogButtonBox::accepted, this, &RingEditDialog::accept); + connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &RingEditDialog::reject); + if (QPushButton *apply_button = _ui->buttonBox->button(QDialogButtonBox::Apply)) { + connect(apply_button, &QPushButton::clicked, this, &RingEditDialog::apply); + } + + auto *color_add = _ui->colorAddButton; + auto *color_remove = _ui->colorRemoveButton; + auto *color_sort = _ui->colorSortButton; + auto *alpha_add = _ui->alphaAddButton; + auto *alpha_remove = _ui->alphaRemoveButton; + auto *alpha_sort = _ui->alphaSortButton; + auto *inner_add = _ui->innerAddButton; + auto *inner_remove = _ui->innerRemoveButton; + auto *inner_sort = _ui->innerSortButton; + auto *outer_add = _ui->outerAddButton; + auto *outer_remove = _ui->outerRemoveButton; + auto *outer_sort = _ui->outerSortButton; + + connect(color_add, &QPushButton::clicked, this, [this, color_specs]() { + const auto time = PromptKeyTime(this, "Add Color Key"); + if (!time) { + return; + } + RingColorChannelClass channel = BuildColorChannel(_colorKeysTable, _ring->Get_Color()); + const Vector3 value = channel.Evaluate(static_cast(*time)); + AddKeyframeRow(_colorKeysTable, {*time, value.X, value.Y, value.Z}, color_specs); + SortKeyframeRows(_colorKeysTable, color_specs); + connectEditorSignals(); + editorChanged(); + }); + connect(color_remove, &QPushButton::clicked, this, [this]() { + RemoveSelectedKeyframeRows(_colorKeysTable); + connectEditorSignals(); + editorChanged(); + }); + connect(color_sort, &QPushButton::clicked, this, [this, color_specs]() { + SortKeyframeRows(_colorKeysTable, color_specs); + connectEditorSignals(); + editorChanged(); + }); + + connect(alpha_add, &QPushButton::clicked, this, [this, alpha_specs]() { + const auto time = PromptKeyTime(this, "Add Opacity Key"); + if (!time) { + return; + } + RingAlphaChannelClass channel = BuildAlphaChannel(_alphaKeysTable, _ring->Get_Alpha()); + const float value = channel.Evaluate(static_cast(*time)); + AddKeyframeRow(_alphaKeysTable, {*time, value}, alpha_specs); + SortKeyframeRows(_alphaKeysTable, alpha_specs); + connectEditorSignals(); + editorChanged(); + }); + connect(alpha_remove, &QPushButton::clicked, this, [this]() { + RemoveSelectedKeyframeRows(_alphaKeysTable); + connectEditorSignals(); + editorChanged(); + }); + connect(alpha_sort, &QPushButton::clicked, this, [this, alpha_specs]() { + SortKeyframeRows(_alphaKeysTable, alpha_specs); + connectEditorSignals(); + editorChanged(); + }); + + connect(inner_add, &QPushButton::clicked, this, [this, scale_specs]() { + const auto time = PromptKeyTime(this, "Add Inner Scale Key"); + if (!time) { + return; + } + RingScaleChannelClass channel = BuildScaleChannel(_innerScaleTable, _ring->Get_Inner_Scale()); + const Vector2 value = channel.Evaluate(static_cast(*time)); + AddKeyframeRow(_innerScaleTable, {*time, value.X, value.Y}, scale_specs); + SortKeyframeRows(_innerScaleTable, scale_specs); + connectEditorSignals(); + editorChanged(); + }); + connect(inner_remove, &QPushButton::clicked, this, [this]() { + RemoveSelectedKeyframeRows(_innerScaleTable); + connectEditorSignals(); + editorChanged(); + }); + connect(inner_sort, &QPushButton::clicked, this, [this, scale_specs]() { + SortKeyframeRows(_innerScaleTable, scale_specs); + connectEditorSignals(); + editorChanged(); + }); + + connect(outer_add, &QPushButton::clicked, this, [this, scale_specs]() { + const auto time = PromptKeyTime(this, "Add Outer Scale Key"); + if (!time) { + return; + } + RingScaleChannelClass channel = BuildScaleChannel(_outerScaleTable, _ring->Get_Outer_Scale()); + const Vector2 value = channel.Evaluate(static_cast(*time)); + AddKeyframeRow(_outerScaleTable, {*time, value.X, value.Y}, scale_specs); + SortKeyframeRows(_outerScaleTable, scale_specs); + connectEditorSignals(); + editorChanged(); + }); + connect(outer_remove, &QPushButton::clicked, this, [this]() { + RemoveSelectedKeyframeRows(_outerScaleTable); + connectEditorSignals(); + editorChanged(); + }); + connect(outer_sort, &QPushButton::clicked, this, [this, scale_specs]() { + SortKeyframeRows(_outerScaleTable, scale_specs); + connectEditorSignals(); + editorChanged(); + }); + + loadFromRing(); + connectEditorSignals(); + updateApplyButton(); +} +RingEditDialog::~RingEditDialog() +{ + if (_lastAppliedRing) { + _lastAppliedRing->Release_Ref(); + _lastAppliedRing = nullptr; + } + if (_ring) { + _ring->Release_Ref(); + _ring = nullptr; + } + delete _ui; +} + +RingRenderObjClass *RingEditDialog::ring() const +{ + if (_ring) { + _ring->Add_Ref(); + } + return _ring; +} + +QString RingEditDialog::oldName() const +{ + return _oldName; +} + +QString RingEditDialog::registeredName() const +{ + return _registeredName; +} + +void RingEditDialog::setApplyHandler(ApplyHandler handler, + const QString ®isteredName, + bool initialApplyRequired) +{ + _applyHandler = std::move(handler); + _registeredName = registeredName; + _initialApplyRequired = initialApplyRequired; + updateApplyButton(); +} + +void RingEditDialog::connectEditorSignals() +{ + if (_nameEdit) { + connect(_nameEdit, + &QLineEdit::textChanged, + this, + &RingEditDialog::editorChanged, + Qt::UniqueConnection); + } + if (_textureEdit) { + connect(_textureEdit, + &QLineEdit::textChanged, + this, + &RingEditDialog::editorChanged, + Qt::UniqueConnection); + } + if (_shaderCombo) { + connect(_shaderCombo, + qOverload(&QComboBox::currentIndexChanged), + this, + &RingEditDialog::editorChanged, + Qt::UniqueConnection); + } + for (QCheckBox *check_box : {_cameraAlignCheck, _loopCheck}) { + if (check_box) { + connect(check_box, + &QCheckBox::toggled, + this, + &RingEditDialog::editorChanged, + Qt::UniqueConnection); + } + } + const auto double_spin_boxes = findChildren(); + for (QDoubleSpinBox *spin_box : double_spin_boxes) { + connect(spin_box, + qOverload(&QDoubleSpinBox::valueChanged), + this, + &RingEditDialog::editorChanged, + Qt::UniqueConnection); + } + const auto spin_boxes = findChildren(); + for (QSpinBox *spin_box : spin_boxes) { + connect(spin_box, + qOverload(&QSpinBox::valueChanged), + this, + &RingEditDialog::editorChanged, + Qt::UniqueConnection); + } +} + +bool RingEditDialog::updateRingFromUi(bool showWarnings) +{ + if (!_ring) { + return false; + } + + const QString name = _nameEdit ? _nameEdit->text().trimmed() : QString(); + if (name.isEmpty()) { + if (showWarnings) { + QMessageBox::warning(this, "Ring", "Invalid ring name. Please enter a new name."); + } + return !showWarnings; + } + + TextureClass *texture = nullptr; + bool can_update_texture = false; + const QString texture_path = _textureEdit ? _textureEdit->text().trimmed() : QString(); + if (texture_path.isEmpty()) { + can_update_texture = true; + } else { + const QString file_name_only = QFileInfo(texture_path).fileName(); + if (file_name_only.isEmpty()) { + if (showWarnings) { + QMessageBox::warning(this, "Ring", "Invalid texture filename."); + } + return !showWarnings; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + if (showWarnings) { + QMessageBox::warning(this, "Ring", "WW3D asset manager is not available."); + return false; + } + } else { + const QByteArray texture_bytes = file_name_only.toLatin1(); + texture = asset_manager->Get_Texture(texture_bytes.constData()); + can_update_texture = true; + } + } + + if (can_update_texture) { + _ring->Set_Texture(texture); + if (texture) { + texture->Release_Ref(); + } + } + + int preset_count = 0; + const ShaderPreset *presets = ShaderPresets(preset_count); + const int shader_index = _shaderCombo ? _shaderCombo->currentData().toInt() : -1; + if (shader_index >= 0 && shader_index < preset_count) { + ShaderClass shader = presets[shader_index].shader; + _ring->Set_Shader(shader); + } else if (_lastAppliedRing) { + ShaderClass shader = _lastAppliedRing->Get_Shader(); + _ring->Set_Shader(shader); + } + + const float lifetime = _lifetimeSpin ? static_cast(_lifetimeSpin->value()) : 0.0f; + _ring->Set_Animation_Duration(lifetime); + + if (_tilingSpin) { + _ring->Set_Texture_Tiling(_tilingSpin->value()); + } + + if (_cameraAlignCheck) { + _ring->Set_Flag(RingRenderObjClass::USE_CAMERA_ALIGN, _cameraAlignCheck->isChecked()); + } + if (_loopCheck) { + _ring->Set_Flag(RingRenderObjClass::USE_ANIMATION_LOOP, _loopCheck->isChecked()); + } + + const RingColorChannelClass color_channel = BuildColorChannel(_colorKeysTable, _ring->Get_Color()); + const RingAlphaChannelClass alpha_channel = BuildAlphaChannel(_alphaKeysTable, _ring->Get_Alpha()); + _ring->Set_Color_Channel(color_channel); + _ring->Set_Alpha_Channel(alpha_channel); + + const float inner_x = _innerXSpin ? static_cast(_innerXSpin->value()) : 0.0f; + const float inner_y = _innerYSpin ? static_cast(_innerYSpin->value()) : 0.0f; + const float outer_x = _outerXSpin ? static_cast(_outerXSpin->value()) : 0.0f; + const float outer_y = _outerYSpin ? static_cast(_outerYSpin->value()) : 0.0f; + _ring->Set_Inner_Extent(Vector2(inner_x, inner_y)); + _ring->Set_Outer_Extent(Vector2(outer_x, outer_y)); + + const RingScaleChannelClass inner_scale = BuildScaleChannel(_innerScaleTable, _ring->Get_Inner_Scale()); + const RingScaleChannelClass outer_scale = BuildScaleChannel(_outerScaleTable, _ring->Get_Outer_Scale()); + _ring->Set_Inner_Scale_Channel(inner_scale); + _ring->Set_Outer_Scale_Channel(outer_scale); + + const QByteArray name_bytes = name.toLatin1(); + _ring->Set_Name(name_bytes.constData()); + _ring->Restart_Animation(); + + return true; +} + +bool RingEditDialog::commitPendingChanges() +{ + const bool has_pending_changes = _initialApplyRequired || _dirty; + if (!updateRingFromUi(true)) { + return false; + } + + if (!has_pending_changes) { + return true; + } + + if (_applyHandler && !_applyHandler(*_ring, _registeredName)) { + return false; + } + + if (const char *name = _ring->Get_Name()) { + _registeredName = QString::fromLatin1(name); + } + if (_lastAppliedRing) { + *_lastAppliedRing = *_ring; + } + _dirty = false; + _initialApplyRequired = false; + updateApplyButton(); + return true; +} + +void RingEditDialog::apply() +{ + commitPendingChanges(); +} + +void RingEditDialog::accept() +{ + if (!commitPendingChanges()) { + return; + } + + QDialog::accept(); +} + +void RingEditDialog::reject() +{ + if (_ring && _lastAppliedRing) { + *_ring = *_lastAppliedRing; + _ring->Restart_Animation(); + } + QDialog::reject(); +} + +void RingEditDialog::editorChanged() +{ + _dirty = true; + updateRingFromUi(false); + updateApplyButton(); +} + +void RingEditDialog::updateApplyButton() +{ + if (!_ui || !_ui->buttonBox) { + return; + } + if (QPushButton *apply_button = _ui->buttonBox->button(QDialogButtonBox::Apply)) { + apply_button->setEnabled(_initialApplyRequired || _dirty); + } +} + +void RingEditDialog::browseTexture() +{ + const QString start = _textureEdit ? _textureEdit->text() : QString(); + const QString path = QFileDialog::getOpenFileName( + this, + "Select Texture", + start, + "Texture Files (*.tga);;All Files (*.*)"); + if (!path.isEmpty() && _textureEdit) { + _textureEdit->setText(path); + } +} + +void RingEditDialog::loadFromRing() +{ + if (!_ring) { + return; + } + + if (_nameEdit) { + const char *name = _ring->Get_Name(); + _nameEdit->setText(name ? QString::fromLatin1(name) : QString()); + } + + if (_textureEdit) { + TextureClass *texture = _ring->Peek_Texture(); + if (texture) { + const StringClass &name = texture->Get_Texture_Name(); + const QByteArray name_bytes(name.Peek_Buffer(), static_cast(name.Get_Length())); + _textureEdit->setText(QString::fromLatin1(name_bytes)); + } + } + + if (_lifetimeSpin) { + _lifetimeSpin->setValue(_ring->Get_Animation_Duration()); + } + + if (_shaderCombo) { + int shader_index = findShaderIndex(); + if (shader_index < 0) { + _shaderCombo->addItem("Custom (preserved)", -1); + shader_index = _shaderCombo->count() - 1; + } + _shaderCombo->setCurrentIndex(shader_index); + } + + if (_tilingSpin) { + _tilingSpin->setValue(_ring->Get_Texture_Tiling()); + } + + const unsigned int flags = _ring->Get_Flags(); + if (_cameraAlignCheck) { + _cameraAlignCheck->setChecked((flags & RingRenderObjClass::USE_CAMERA_ALIGN) != 0); + } + if (_loopCheck) { + _loopCheck->setChecked((flags & RingRenderObjClass::USE_ANIMATION_LOOP) != 0); + } + + if (_innerXSpin && _innerYSpin) { + const Vector2 inner = _ring->Get_Inner_Extent(); + _innerXSpin->setValue(inner.X); + _innerYSpin->setValue(inner.Y); + } + + if (_outerXSpin && _outerYSpin) { + const Vector2 outer = _ring->Get_Outer_Extent(); + _outerXSpin->setValue(outer.X); + _outerYSpin->setValue(outer.Y); + } + + RingColorChannelClass color_channel = _ring->Get_Color_Channel(); + if (color_channel.Get_Key_Count() == 0) { + color_channel.Add_Key(_ring->Get_Color(), 0.0f); + } + QVector> color_rows; + for (int i = 0; i < color_channel.Get_Key_Count(); ++i) { + const auto &key = color_channel.Get_Key(i); + const Vector3 value = key.Get_Value(); + color_rows.push_back({key.Get_Time(), value.X, value.Y, value.Z}); + } + SetKeyframeRows(_colorKeysTable, + color_rows, + QVector{{0.0, 1.0, 3}, + {0.0, 1.0, 3}, + {0.0, 1.0, 3}, + {0.0, 1.0, 3}}); + + RingAlphaChannelClass alpha_channel = _ring->Get_Alpha_Channel(); + if (alpha_channel.Get_Key_Count() == 0) { + alpha_channel.Add_Key(_ring->Get_Alpha(), 0.0f); + } + QVector> alpha_rows; + for (int i = 0; i < alpha_channel.Get_Key_Count(); ++i) { + const auto &key = alpha_channel.Get_Key(i); + alpha_rows.push_back({key.Get_Time(), key.Get_Value()}); + } + SetKeyframeRows(_alphaKeysTable, + alpha_rows, + QVector{{0.0, 1.0, 3}, {0.0, 1.0, 3}}); + + RingScaleChannelClass inner_scale = _ring->Get_Inner_Scale_Channel(); + if (inner_scale.Get_Key_Count() == 0) { + inner_scale.Add_Key(_ring->Get_Inner_Scale(), 0.0f); + } + QVector> inner_rows; + for (int i = 0; i < inner_scale.Get_Key_Count(); ++i) { + const auto &key = inner_scale.Get_Key(i); + const Vector2 value = key.Get_Value(); + inner_rows.push_back({key.Get_Time(), value.X, value.Y}); + } + SetKeyframeRows(_innerScaleTable, + inner_rows, + QVector{{0.0, 1.0, 3}, + {0.0, 10000.0, 3}, + {0.0, 10000.0, 3}}); + + RingScaleChannelClass outer_scale = _ring->Get_Outer_Scale_Channel(); + if (outer_scale.Get_Key_Count() == 0) { + outer_scale.Add_Key(_ring->Get_Outer_Scale(), 0.0f); + } + QVector> outer_rows; + for (int i = 0; i < outer_scale.Get_Key_Count(); ++i) { + const auto &key = outer_scale.Get_Key(i); + const Vector2 value = key.Get_Value(); + outer_rows.push_back({key.Get_Time(), value.X, value.Y}); + } + SetKeyframeRows(_outerScaleTable, + outer_rows, + QVector{{0.0, 1.0, 3}, + {0.0, 10000.0, 3}, + {0.0, 10000.0, 3}}); +} + +int RingEditDialog::findShaderIndex() const +{ + if (!_ring) { + return -1; + } + + int preset_count = 0; + const ShaderPreset *presets = ShaderPresets(preset_count); + const ShaderClass &shader = _ring->Get_Shader(); + for (int index = 0; index < preset_count; ++index) { + if (ShaderMatches(presets[index].shader, shader)) { + return index; + } + } + + return -1; +} diff --git a/Code/Tools/W3DViewQt/RingEditDialog.h b/Code/Tools/W3DViewQt/RingEditDialog.h new file mode 100644 index 000000000..9bafe9255 --- /dev/null +++ b/Code/Tools/W3DViewQt/RingEditDialog.h @@ -0,0 +1,79 @@ +#pragma once + +#include +#include +#include + +class QCheckBox; +class QComboBox; +class QDoubleSpinBox; +class QLineEdit; +class QSpinBox; +class QTableWidget; +class RingRenderObjClass; + +namespace Ui { +class RingEditDialog; +} + +class RingEditDialog final : public QDialog +{ + Q_OBJECT + +public: + using ApplyHandler = std::function; + + explicit RingEditDialog(RingRenderObjClass *ring, QWidget *parent = nullptr); + ~RingEditDialog() override; + + RingRenderObjClass *ring() const; + QString oldName() const; + QString registeredName() const; + void setApplyHandler(ApplyHandler handler, + const QString ®isteredName, + bool initialApplyRequired = false); + +protected: + void accept() override; + void reject() override; + +private slots: + void apply(); + void browseTexture(); + void editorChanged(); + +private: + void connectEditorSignals(); + void loadFromRing(); + bool updateRingFromUi(bool showWarnings); + bool commitPendingChanges(); + void updateApplyButton(); + int findShaderIndex() const; + + Ui::RingEditDialog *_ui = nullptr; + RingRenderObjClass *_ring = nullptr; + RingRenderObjClass *_lastAppliedRing = nullptr; + QString _oldName; + QString _registeredName; + ApplyHandler _applyHandler; + bool _dirty = false; + bool _initialApplyRequired = false; + + QLineEdit *_nameEdit = nullptr; + QLineEdit *_textureEdit = nullptr; + QDoubleSpinBox *_lifetimeSpin = nullptr; + QComboBox *_shaderCombo = nullptr; + QCheckBox *_cameraAlignCheck = nullptr; + QCheckBox *_loopCheck = nullptr; + QSpinBox *_tilingSpin = nullptr; + + QTableWidget *_colorKeysTable = nullptr; + QTableWidget *_alphaKeysTable = nullptr; + + QDoubleSpinBox *_innerXSpin = nullptr; + QDoubleSpinBox *_innerYSpin = nullptr; + QDoubleSpinBox *_outerXSpin = nullptr; + QDoubleSpinBox *_outerYSpin = nullptr; + QTableWidget *_innerScaleTable = nullptr; + QTableWidget *_outerScaleTable = nullptr; +}; diff --git a/Code/Tools/W3DViewQt/RingEditDialog.ui b/Code/Tools/W3DViewQt/RingEditDialog.ui new file mode 100644 index 000000000..2088e59e1 --- /dev/null +++ b/Code/Tools/W3DViewQt/RingEditDialog.ui @@ -0,0 +1,536 @@ + + + RingEditDialog + + + Ring Properties + + + + + + 0 + + + + General + + + + + + Name: + + + nameEdit + + + + + + + 31 + + + + + + + Texture: + + + textureEdit + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + Browse... + + + + + + + + + + Shader: + + + shaderCombo + + + + + + + + + + Lifetime: + + + lifetimeSpin + + + + + + + 2 + + + 1000.000000000000000 + + + + + + + Texture Tiling: + + + tilingSpin + + + + + + + 8 + + + + + + + Flags: + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Camera Align + + + + + + + Looping + + + + + + + + + + + Color + + + + + + Color Keys + + + + + + QAbstractItemView::NoEditTriggers + + + QAbstractItemView::ExtendedSelection + + + QAbstractItemView::SelectRows + + + false + + + + Time + + + + + R + + + + + G + + + + + B + + + + + + + + + + Add + + + + + + + Remove + + + + + + + Sort + + + + + + + + + + + + Opacity Keys + + + + + + QAbstractItemView::NoEditTriggers + + + QAbstractItemView::ExtendedSelection + + + QAbstractItemView::SelectRows + + + false + + + + Time + + + + + Alpha + + + + + + + + + + Add + + + + + + + Remove + + + + + + + Sort + + + + + + + + + + + + + Size + + + + + + + + Inner Extent X: + + + innerXSpin + + + + + + + 2 + + + 10000.000000000000000 + + + + + + + Inner Extent Y: + + + innerYSpin + + + + + + + 2 + + + 10000.000000000000000 + + + + + + + Outer Extent X: + + + outerXSpin + + + + + + + 2 + + + 10000.000000000000000 + + + + + + + Outer Extent Y: + + + outerYSpin + + + + + + + 2 + + + 10000.000000000000000 + + + + + + + + + Inner Scale Keys + + + + + + QAbstractItemView::NoEditTriggers + + + QAbstractItemView::ExtendedSelection + + + QAbstractItemView::SelectRows + + + false + + + + Time + + + + + X + + + + + Y + + + + + + + + + + Add + + + + + + + Remove + + + + + + + Sort + + + + + + + + + + + + Outer Scale Keys + + + + + + QAbstractItemView::NoEditTriggers + + + QAbstractItemView::ExtendedSelection + + + QAbstractItemView::SelectRows + + + false + + + + Time + + + + + X + + + + + Y + + + + + + + + + + Add + + + + + + + Remove + + + + + + + Sort + + + + + + + + + + + + + + + + QDialogButtonBox::Apply|QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + diff --git a/Code/Tools/W3DViewQt/SaveSettingsDialog.cpp b/Code/Tools/W3DViewQt/SaveSettingsDialog.cpp new file mode 100644 index 000000000..40f3c42ae --- /dev/null +++ b/Code/Tools/W3DViewQt/SaveSettingsDialog.cpp @@ -0,0 +1,101 @@ +#include "SaveSettingsDialog.h" + +#include "ui_SaveSettingsDialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +SaveSettingsDialog::SaveSettingsDialog(QWidget *parent) + : QDialog(parent) + , _ui(new Ui::SaveSettingsDialog) +{ + _ui->setupUi(this); + + connect(_ui->browseButton, &QPushButton::clicked, this, &SaveSettingsDialog::browse); + connect(_ui->pathLineEdit, + &QLineEdit::textChanged, + this, + &SaveSettingsDialog::updateOkEnabled); + connect(_ui->lightingCheckBox, + &QCheckBox::toggled, + this, + &SaveSettingsDialog::updateOkEnabled); + connect(_ui->backgroundCheckBox, + &QCheckBox::toggled, + this, + &SaveSettingsDialog::updateOkEnabled); + connect(_ui->buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + + updateOkEnabled(); +} + +SaveSettingsDialog::~SaveSettingsDialog() +{ + delete _ui; +} + +QString SaveSettingsDialog::selectedPath() const +{ + QString path = QDir::fromNativeSeparators(_ui->pathLineEdit->text().trimmed()); + if (path.isEmpty()) { + return {}; + } + + if (QFileInfo(path).isRelative()) { + path = QDir(QCoreApplication::applicationDirPath()).filePath(path); + } + return QDir::toNativeSeparators(QDir::cleanPath(path)); +} + +bool SaveSettingsDialog::saveLighting() const +{ + return _ui->lightingCheckBox->isChecked(); +} + +bool SaveSettingsDialog::saveBackground() const +{ + return _ui->backgroundCheckBox->isChecked(); +} + +void SaveSettingsDialog::browse() +{ + QString initial_path = selectedPath(); + if (initial_path.isEmpty()) { + initial_path = QStringLiteral("Default.dat"); + } + + QFileInfo initial_info(initial_path); + if (initial_info.isRelative()) { + initial_info.setFile(QDir(QCoreApplication::applicationDirPath()), initial_path); + } + + QFileDialog dialog(this, tr("Save Settings")); + dialog.setAcceptMode(QFileDialog::AcceptSave); + dialog.setFileMode(QFileDialog::AnyFile); + dialog.setDefaultSuffix(QStringLiteral("dat")); + dialog.setNameFilter(tr("Setting data files (*.dat);;All Files (*.*)")); + dialog.setDirectory(initial_info.absolutePath()); + dialog.selectFile(initial_info.fileName()); + + if (dialog.exec() != QDialog::Accepted || dialog.selectedFiles().isEmpty()) { + return; + } + + _ui->pathLineEdit->setText(QDir::toNativeSeparators(dialog.selectedFiles().at(0))); +} + +void SaveSettingsDialog::updateOkEnabled() +{ + const bool has_path = !selectedPath().isEmpty(); + const bool has_supported_category = saveLighting() || saveBackground(); + if (QPushButton *ok_button = _ui->buttonBox->button(QDialogButtonBox::Ok)) { + ok_button->setEnabled(has_path && has_supported_category); + } +} diff --git a/Code/Tools/W3DViewQt/SaveSettingsDialog.h b/Code/Tools/W3DViewQt/SaveSettingsDialog.h new file mode 100644 index 000000000..ef42046e9 --- /dev/null +++ b/Code/Tools/W3DViewQt/SaveSettingsDialog.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +namespace Ui { +class SaveSettingsDialog; +} + +class SaveSettingsDialog final : public QDialog +{ + Q_OBJECT + +public: + explicit SaveSettingsDialog(QWidget *parent = nullptr); + ~SaveSettingsDialog() override; + + QString selectedPath() const; + bool saveLighting() const; + bool saveBackground() const; + +private slots: + void browse(); + void updateOkEnabled(); + +private: + Ui::SaveSettingsDialog *_ui = nullptr; +}; diff --git a/Code/Tools/W3DViewQt/SaveSettingsDialog.ui b/Code/Tools/W3DViewQt/SaveSettingsDialog.ui new file mode 100644 index 000000000..fbd585227 --- /dev/null +++ b/Code/Tools/W3DViewQt/SaveSettingsDialog.ui @@ -0,0 +1,122 @@ + + + SaveSettingsDialog + + + Save Settings + + + + + + Select which settings you want saved. Enter a filename to save the settings under. + + + true + + + + + + + &Settings saved + + + + + + &Lighting + + + true + + + + + + + &Background + + + true + + + + + + + false + + + Camera settings are not currently available. + + + &Camera + + + true + + + + + + + + + + + + &Filename: + + + pathLineEdit + + + + + + + + 300 + 0 + + + + Default.dat + + + true + + + + + + + &Browse... + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + lightingCheckBox + backgroundCheckBox + pathLineEdit + browseButton + buttonBox + + + + diff --git a/Code/Tools/W3DViewQt/ScaleDialog.cpp b/Code/Tools/W3DViewQt/ScaleDialog.cpp new file mode 100644 index 000000000..3697cd0d6 --- /dev/null +++ b/Code/Tools/W3DViewQt/ScaleDialog.cpp @@ -0,0 +1,38 @@ +#include "ScaleDialog.h" + +#include "ui_ScaleDialog.h" + +#include +#include + +ScaleDialog::ScaleDialog(double scale, const QString &prompt, QWidget *parent) + : QDialog(parent) + , _ui(new Ui::ScaleDialog) +{ + _ui->setupUi(this); + _ui->promptLabel->setText(prompt); + _ui->scaleSpinBox->setValue(scale); + + connect(_ui->buttonBox, &QDialogButtonBox::accepted, this, &ScaleDialog::accept); + connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); +} + +ScaleDialog::~ScaleDialog() +{ + delete _ui; +} + +double ScaleDialog::scale() const +{ + return _ui->scaleSpinBox->value(); +} + +void ScaleDialog::accept() +{ + if (_ui->scaleSpinBox->value() <= 0.0) { + QMessageBox::information(this, "Invalid Scale", "Scale must be a value greater than zero."); + return; + } + + QDialog::accept(); +} diff --git a/Code/Tools/W3DViewQt/ScaleDialog.h b/Code/Tools/W3DViewQt/ScaleDialog.h new file mode 100644 index 000000000..78b7ab568 --- /dev/null +++ b/Code/Tools/W3DViewQt/ScaleDialog.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +namespace Ui { +class ScaleDialog; +} + +class ScaleDialog final : public QDialog +{ + Q_OBJECT + +public: + explicit ScaleDialog(double scale, const QString &prompt, QWidget *parent = nullptr); + ~ScaleDialog() override; + + double scale() const; + +protected: + void accept() override; + +private: + Ui::ScaleDialog *_ui = nullptr; +}; diff --git a/Code/Tools/W3DViewQt/ScaleDialog.ui b/Code/Tools/W3DViewQt/ScaleDialog.ui new file mode 100644 index 000000000..62e8f906d --- /dev/null +++ b/Code/Tools/W3DViewQt/ScaleDialog.ui @@ -0,0 +1,49 @@ + + + ScaleDialog + + + Scale + + + + + + Scale: + + + true + + + scaleSpinBox + + + + + + + 2 + + + 0.010000000000000 + + + 100.000000000000000 + + + 1.000000000000000 + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + diff --git a/Code/Tools/W3DViewQt/SceneLightDialog.cpp b/Code/Tools/W3DViewQt/SceneLightDialog.cpp new file mode 100644 index 000000000..cf034d50a --- /dev/null +++ b/Code/Tools/W3DViewQt/SceneLightDialog.cpp @@ -0,0 +1,156 @@ +#include "SceneLightDialog.h" + +#include "W3DViewport.h" +#include "ui_SceneLightDialog.h" + +#include +#include +#include +#include +#include + +SceneLightDialog::SceneLightDialog(W3DViewport &viewport, QWidget *parent) + : QDialog(parent) + , _ui(new Ui::SceneLightDialog) + , _viewport(&viewport) +{ + _ui->setupUi(this); + + _initialState = _viewport->sceneLightState(); + + setColorControls(_initialState.diffuse); + _ui->distanceSpinBox->setValue(_initialState.distance); + _ui->intensitySlider->setValue(static_cast(_initialState.intensity * 100.0f)); + _ui->attenuationStartSpinBox->setValue(_initialState.attenuationStart); + _ui->attenuationEndSpinBox->setValue(_initialState.attenuationEnd); + _ui->attenuationGroupBox->setChecked(_initialState.attenuationEnabled); + updateAttenuationControls(_initialState.attenuationEnabled); + + connect(_ui->redSlider, &QSlider::valueChanged, this, + [this](int value) { colorSliderChanged(_ui->redSlider, value); }); + connect(_ui->greenSlider, &QSlider::valueChanged, this, + [this](int value) { colorSliderChanged(_ui->greenSlider, value); }); + connect(_ui->blueSlider, &QSlider::valueChanged, this, + [this](int value) { colorSliderChanged(_ui->blueSlider, value); }); + connect(_ui->grayscaleCheckBox, &QCheckBox::toggled, this, [this](bool enabled) { + if (enabled) { + const QSignalBlocker green_blocker(_ui->greenSlider); + const QSignalBlocker blue_blocker(_ui->blueSlider); + _ui->greenSlider->setValue(_ui->redSlider->value()); + _ui->blueSlider->setValue(_ui->redSlider->value()); + applyColorFromControls(); + } + }); + + connect(_ui->diffuseRadioButton, &QRadioButton::toggled, this, [this](bool checked) { + if (checked) { + _currentChannel = Diffuse; + setColorControls(_viewport->sceneLightDiffuse()); + } + }); + connect(_ui->specularRadioButton, &QRadioButton::toggled, this, [this](bool checked) { + if (checked) { + _currentChannel = Specular; + setColorControls(_viewport->sceneLightSpecular()); + } + }); + connect(_ui->bothRadioButton, &QRadioButton::toggled, this, [this](bool checked) { + if (checked) { + _currentChannel = Both; + } + }); + + connect(_ui->intensitySlider, &QSlider::valueChanged, this, [this](int value) { + _viewport->setSceneLightIntensity(static_cast(value) / 100.0f); + }); + connect(_ui->distanceSpinBox, qOverload(&QDoubleSpinBox::valueChanged), this, + [this](double value) { _viewport->setSceneLightDistance(static_cast(value)); }); + connect(_ui->attenuationStartSpinBox, qOverload(&QDoubleSpinBox::valueChanged), this, + [this](double) { applyAttenuation(); }); + connect(_ui->attenuationEndSpinBox, qOverload(&QDoubleSpinBox::valueChanged), this, + [this](double) { applyAttenuation(); }); + connect(_ui->attenuationGroupBox, &QGroupBox::toggled, this, [this](bool enabled) { + updateAttenuationControls(enabled); + applyAttenuation(); + }); + + connect(_ui->buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &SceneLightDialog::reject); +} + +SceneLightDialog::~SceneLightDialog() +{ + delete _ui; +} + +void SceneLightDialog::reject() +{ + if (_viewport) { + _viewport->setSceneLightState(_initialState); + } + + QDialog::reject(); +} + +void SceneLightDialog::setColorControls(const Vector3 &color) +{ + const QSignalBlocker red_blocker(_ui->redSlider); + const QSignalBlocker green_blocker(_ui->greenSlider); + const QSignalBlocker blue_blocker(_ui->blueSlider); + const QSignalBlocker grayscale_blocker(_ui->grayscaleCheckBox); + + _ui->redSlider->setValue(static_cast(color.X * 100.0f)); + _ui->greenSlider->setValue(static_cast(color.Y * 100.0f)); + _ui->blueSlider->setValue(static_cast(color.Z * 100.0f)); + _ui->grayscaleCheckBox->setChecked(color.X == color.Y && color.X == color.Z); +} + +void SceneLightDialog::applyColorFromControls() +{ + const Vector3 color(static_cast(_ui->redSlider->value()) / 100.0f, + static_cast(_ui->greenSlider->value()) / 100.0f, + static_cast(_ui->blueSlider->value()) / 100.0f); + + if (_currentChannel & Diffuse) { + _viewport->setSceneLightDiffuse(color); + } + if (_currentChannel & Specular) { + _viewport->setSceneLightSpecular(color); + } +} + +void SceneLightDialog::colorSliderChanged(QSlider *source, int value) +{ + if (_ui->grayscaleCheckBox->isChecked()) { + const QSignalBlocker red_blocker(_ui->redSlider); + const QSignalBlocker green_blocker(_ui->greenSlider); + const QSignalBlocker blue_blocker(_ui->blueSlider); + if (source != _ui->redSlider) { + _ui->redSlider->setValue(value); + } + if (source != _ui->greenSlider) { + _ui->greenSlider->setValue(value); + } + if (source != _ui->blueSlider) { + _ui->blueSlider->setValue(value); + } + } + + applyColorFromControls(); +} + +void SceneLightDialog::applyAttenuation() +{ + _viewport->setSceneLightAttenuation( + static_cast(_ui->attenuationStartSpinBox->value()), + static_cast(_ui->attenuationEndSpinBox->value()), + _ui->attenuationGroupBox->isChecked()); +} + +void SceneLightDialog::updateAttenuationControls(bool enabled) +{ + _ui->attenuationStartLabel->setEnabled(enabled); + _ui->attenuationStartSpinBox->setEnabled(enabled); + _ui->attenuationEndLabel->setEnabled(enabled); + _ui->attenuationEndSpinBox->setEnabled(enabled); +} diff --git a/Code/Tools/W3DViewQt/SceneLightDialog.h b/Code/Tools/W3DViewQt/SceneLightDialog.h new file mode 100644 index 000000000..c584b9ada --- /dev/null +++ b/Code/Tools/W3DViewQt/SceneLightDialog.h @@ -0,0 +1,40 @@ +#pragma once + +#include "W3DViewport.h" + +#include + +class QSlider; +namespace Ui { +class SceneLightDialog; +} + +class SceneLightDialog final : public QDialog +{ + Q_OBJECT + +public: + explicit SceneLightDialog(W3DViewport &viewport, QWidget *parent = nullptr); + ~SceneLightDialog() override; + +public slots: + void reject() override; + +private: + enum Channel { + Diffuse = 1 << 0, + Specular = 1 << 1, + Both = Diffuse | Specular, + }; + + void setColorControls(const Vector3 &color); + void applyColorFromControls(); + void colorSliderChanged(QSlider *source, int value); + void applyAttenuation(); + void updateAttenuationControls(bool enabled); + + Ui::SceneLightDialog *_ui = nullptr; + W3DViewport *_viewport = nullptr; + Channel _currentChannel = Diffuse; + W3DViewport::SceneLightState _initialState; +}; diff --git a/Code/Tools/W3DViewQt/SceneLightDialog.ui b/Code/Tools/W3DViewQt/SceneLightDialog.ui new file mode 100644 index 000000000..8f8f9b3fd --- /dev/null +++ b/Code/Tools/W3DViewQt/SceneLightDialog.ui @@ -0,0 +1,304 @@ + + + SceneLightDialog + + + Scene Light + + + + + + &Channel + + + + + + + + &Diffuse + + + true + + + + + + + S&pecular + + + + + + + &Both + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + &Red + + + redSlider + + + + + + + + 180 + 8 + + + + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 black, stop:1 red); border: 1px solid palette(mid); + + + QFrame::StyledPanel + + + + + + + 100 + + + Qt::Horizontal + + + + + + + Gree&n + + + greenSlider + + + + + + + + 180 + 8 + + + + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 black, stop:1 lime); border: 1px solid palette(mid); + + + QFrame::StyledPanel + + + + + + + 100 + + + Qt::Horizontal + + + + + + + Bl&ue + + + blueSlider + + + + + + + + 180 + 8 + + + + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 black, stop:1 blue); border: 1px solid palette(mid); + + + QFrame::StyledPanel + + + + + + + 100 + + + Qt::Horizontal + + + + + + + &Grayscale + + + + + + + + + + + + + + &Intensity: + + + intensitySlider + + + + + + + 100 + + + Qt::Horizontal + + + + + + + Dis&tance: + + + distanceSpinBox + + + + + + + 2 + + + 1000000.000000000000000 + + + 0.010000000000000 + + + + + + + + + &Attenuation + + + true + + + + + + &Start: + + + attenuationStartSpinBox + + + + + + + 2 + + + 1000000.000000000000000 + + + 0.010000000000000 + + + + + + + &End: + + + attenuationEndSpinBox + + + + + + + 2 + + + 1000000.000000000000000 + + + 0.010000000000000 + + + + + + + + + + To reposition the scene light, hold Ctrl and drag with the left mouse button in the viewport. Ctrl+right-drag changes its distance. + + + true + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + diff --git a/Code/Tools/W3DViewQt/SoundEditDialog.cpp b/Code/Tools/W3DViewQt/SoundEditDialog.cpp new file mode 100644 index 000000000..2b4c69966 --- /dev/null +++ b/Code/Tools/W3DViewQt/SoundEditDialog.cpp @@ -0,0 +1,312 @@ +#include "SoundEditDialog.h" + +#include "PlaySoundDialog.h" +#include "ui_SoundEditDialog.h" + +#include "AudibleSound.h" +#include "Sound3D.h" +#include "WWAudio.h" +#include "assetmgr.h" +#include "soundrobj.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr float kDefaultDropOff = 100.0f; +constexpr float kDefaultMaxVol = 10.0f; +constexpr float kDefaultPriority = 0.5f; +constexpr float kDefaultVolume = 1.0f; +constexpr int kMaxSoundObjectNameLength = 15; + +const AudibleSoundDefinitionClass *findPrototypeSoundDefinition( + const SoundRenderObjClass *renderObject) +{ + auto *assetManager = WW3DAssetManager::Get_Instance(); + const char *objectName = renderObject ? renderObject->Get_Name() : nullptr; + if (!assetManager || !objectName || objectName[0] == '\0') { + return nullptr; + } + + auto *prototype = dynamic_cast( + assetManager->Find_Prototype(objectName)); + SoundRenderObjDefClass *renderDefinition = + prototype ? prototype->Peek_Definition() : nullptr; + return renderDefinition ? renderDefinition->Peek_Sound_Definition() : nullptr; +} +} + +SoundEditDialog::SoundEditDialog(SoundRenderObjClass *sound, QWidget *parent) + : QDialog(parent) + , _ui(new Ui::SoundEditDialog) +{ + _ui->setupUi(this); + + if (sound) { + _sound = sound; + _sound->Add_Ref(); + } else { + _sound = new SoundRenderObjClass; + } + + if (_sound && _sound->Get_Name()) { + _oldName = QString::fromLatin1(_sound->Get_Name()); + } + + connect(_ui->browseButton, &QPushButton::clicked, this, &SoundEditDialog::browseSoundFile); + connect(_ui->playButton, &QPushButton::clicked, this, &SoundEditDialog::playSound); + connect(_ui->buttonBox, &QDialogButtonBox::accepted, this, &SoundEditDialog::accept); + connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &SoundEditDialog::reject); + connect(_ui->radio2d, &QRadioButton::toggled, this, &SoundEditDialog::toggleSoundType); + connect(_ui->radio3d, &QRadioButton::toggled, this, &SoundEditDialog::toggleSoundType); + + loadFromSound(); + updateEnableState(); +} + +SoundEditDialog::~SoundEditDialog() +{ + if (_sound) { + _sound->Release_Ref(); + _sound = nullptr; + } + delete _ui; +} + +SoundRenderObjClass *SoundEditDialog::sound() const +{ + if (_sound) { + _sound->Add_Ref(); + } + return _sound; +} + +QString SoundEditDialog::oldName() const +{ + return _oldName; +} + +void SoundEditDialog::accept() +{ + if (!_sound) { + QDialog::reject(); + return; + } + + const QString name = _ui->nameEdit->text().trimmed(); + if (name.isEmpty()) { + QMessageBox::warning(this, "Sound Object", "Invalid object name. Please enter a new name."); + return; + } + + const QByteArray name_bytes = name.toLatin1(); + if (name_bytes.size() > kMaxSoundObjectNameLength) { + QMessageBox::warning( + this, + "Sound Object", + QString("Sound object names are limited to %1 characters.") + .arg(kMaxSoundObjectNameLength)); + return; + } + + auto *audio = WWAudioClass::Get_Instance(); + if (!audio) { + QMessageBox::warning(this, "Sound Object", "Audio system is not available."); + return; + } + + const QString filename = _ui->fileEdit->text().trimmed(); + const QString file_name_only = QFileInfo(filename).fileName(); + if (file_name_only.isEmpty()) { + QMessageBox::warning(this, "Sound Object", "Invalid sound filename."); + return; + } + + AudibleSoundClass *sound = nullptr; + const bool is_3d = _ui->radio3d->isChecked(); + const QByteArray file_bytes = file_name_only.toLatin1(); + if (is_3d) { + sound = audio->Create_3D_Sound(file_bytes.constData()); + } else { + sound = audio->Create_Sound_Effect(file_bytes.constData()); + } + + // Create_3D_Sound can return a pseudo-3D object with no buffer when the + // file could not be resolved. Treat that as creation failure instead of + // serializing an object whose definition has an empty filename. + if (sound && (!sound->Get_Filename() || sound->Get_Filename()[0] == '\0')) { + sound->Release_Ref(); + sound = nullptr; + } + + if (!sound) { + QMessageBox::warning( + this, + "Sound Object", + QString("Failed to create sound object from: %1").arg(file_name_only)); + return; + } + + const float priority = _ui->prioritySlider->value() / 100.0f; + const float volume = _ui->volumeSlider->value() / 100.0f; + sound->Set_Priority(priority); + sound->Set_Volume(volume); + + const int loop_count = _ui->infiniteLoops->isChecked() ? 0 : 1; + sound->Set_Loop_Count(loop_count); + + const bool is_music = _ui->radioMusic->isChecked(); + sound->Set_Type(is_music ? AudibleSoundClass::TYPE_MUSIC + : AudibleSoundClass::TYPE_SOUND_EFFECT); + + float drop_off = static_cast(_ui->dropOffEdit->value()); + float max_vol = static_cast(_ui->maxVolEdit->value()); + float trigger = static_cast(_ui->triggerRadiusEdit->value()); + + if (is_3d) { + sound->Set_DropOff_Radius(drop_off); + auto *sound_3d = sound->As_Sound3DClass(); + if (sound_3d) { + sound_3d->Set_Max_Vol_Radius(max_vol); + } + } else { + sound->Set_DropOff_Radius(trigger); + } + + AudibleSoundDefinitionClass definition; + definition.Initialize_From_Sound(sound); + sound->Release_Ref(); + + _sound->Set_Sound(&definition); + + if (_ui->stopWhenHidden->isChecked()) { + _sound->Set_Flags(SoundRenderObjClass::FLAG_STOP_WHEN_HIDDEN); + } else { + _sound->Set_Flags(0); + } + + _sound->Set_Name(name_bytes.constData()); + + QDialog::accept(); +} + +void SoundEditDialog::browseSoundFile() +{ + const QString start = _ui->fileEdit->text(); + const QString path = QFileDialog::getOpenFileName( + this, + "Select Sound File", + start, + "All Sound Files (*.wav *.mp3);;WAV Files (*.wav);;MP3 Files (*.mp3)"); + if (!path.isEmpty()) { + _ui->fileEdit->setText(QFileInfo(path).fileName()); + } +} + +void SoundEditDialog::toggleSoundType() +{ + updateEnableState(); +} + +void SoundEditDialog::playSound() +{ + const QString filename = _ui->fileEdit->text().trimmed(); + if (filename.isEmpty()) { + QMessageBox::warning(this, "Play Sound", "No sound file specified."); + return; + } + + PlaySoundDialog dialog(filename, this); + if (dialog.isReady()) { + dialog.exec(); + } +} + +void SoundEditDialog::loadFromSound() +{ + if (!_sound) { + return; + } + + const char *name = _sound->Get_Name(); + if (name) { + _ui->nameEdit->setText(QString::fromLatin1(name)); + } + + bool stop_on_hide = _sound->Get_Flag(SoundRenderObjClass::FLAG_STOP_WHEN_HIDDEN); + float drop_off_radius = kDefaultDropOff; + float max_vol_radius = kDefaultMaxVol; + float priority = kDefaultPriority; + bool is_3d = true; + bool is_music = false; + int loop_count = 1; + float volume = kDefaultVolume; + QString filename; + + AudibleSoundClass *sound = _sound->Peek_Sound(); + const AudibleSoundDefinitionClass *definition = + sound ? sound->Get_Definition() : nullptr; + if (!definition) { + definition = findPrototypeSoundDefinition(_sound); + } + if (sound) { + const char *runtime_filename = sound->Get_Filename(); + if (runtime_filename && runtime_filename[0] != '\0') { + filename = QString::fromLocal8Bit(runtime_filename); + } else if (definition) { + const char *definition_filename = definition->Get_Filename(); + if (definition_filename && definition_filename[0] != '\0') { + filename = QString::fromLocal8Bit(definition_filename); + } + } + drop_off_radius = sound->Get_DropOff_Radius(); + priority = sound->Peek_Priority(); + is_3d = sound->As_Sound3DClass() != nullptr; + is_music = sound->Get_Type() == AudibleSoundClass::TYPE_MUSIC; + loop_count = sound->Get_Loop_Count(); + volume = sound->Get_Volume(); + + auto *sound_3d = sound->As_Sound3DClass(); + if (sound_3d) { + max_vol_radius = sound_3d->Get_Max_Vol_Radius(); + } + } else if (definition) { + const char *definition_filename = definition->Get_Filename(); + if (definition_filename && definition_filename[0] != '\0') { + filename = QString::fromLocal8Bit(definition_filename); + } + drop_off_radius = definition->Get_DropOff_Radius(); + max_vol_radius = definition->Get_Max_Vol_Radius(); + priority = definition->Get_Priority(); + is_3d = definition->Is_3D(); + is_music = definition->Get_Type() == AudibleSoundClass::TYPE_MUSIC; + loop_count = definition->Get_Loop_Count(); + volume = definition->Get_Volume(); + } + + _ui->fileEdit->setText(filename); + _ui->infiniteLoops->setChecked(loop_count == 0); + _ui->radio3d->setChecked(is_3d); + _ui->radio2d->setChecked(!is_3d); + _ui->radioMusic->setChecked(is_music); + _ui->radioEffect->setChecked(!is_music); + _ui->stopWhenHidden->setChecked(stop_on_hide); + _ui->volumeSlider->setValue(static_cast(volume * 100.0f)); + _ui->prioritySlider->setValue(static_cast(priority * 100.0f)); + _ui->dropOffEdit->setValue(drop_off_radius); + _ui->maxVolEdit->setValue(max_vol_radius); + _ui->triggerRadiusEdit->setValue(drop_off_radius); +} + +void SoundEditDialog::updateEnableState() +{ + const bool enable_3d = _ui->radio3d->isChecked(); + _ui->maxVolEdit->setEnabled(enable_3d); + _ui->dropOffEdit->setEnabled(enable_3d); + _ui->triggerRadiusEdit->setEnabled(!enable_3d); +} diff --git a/Code/Tools/W3DViewQt/SoundEditDialog.h b/Code/Tools/W3DViewQt/SoundEditDialog.h new file mode 100644 index 000000000..c28076af1 --- /dev/null +++ b/Code/Tools/W3DViewQt/SoundEditDialog.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +class SoundRenderObjClass; + +namespace Ui { +class SoundEditDialog; +} + +class SoundEditDialog final : public QDialog +{ + Q_OBJECT + +public: + explicit SoundEditDialog(SoundRenderObjClass *sound, QWidget *parent = nullptr); + ~SoundEditDialog() override; + + SoundRenderObjClass *sound() const; + QString oldName() const; + +protected: + void accept() override; + +private slots: + void browseSoundFile(); + void toggleSoundType(); + void playSound(); + +private: + void loadFromSound(); + void updateEnableState(); + + Ui::SoundEditDialog *_ui = nullptr; + SoundRenderObjClass *_sound = nullptr; + QString _oldName; +}; diff --git a/Code/Tools/W3DViewQt/SoundEditDialog.ui b/Code/Tools/W3DViewQt/SoundEditDialog.ui new file mode 100644 index 000000000..13e2afeb5 --- /dev/null +++ b/Code/Tools/W3DViewQt/SoundEditDialog.ui @@ -0,0 +1,247 @@ + + + SoundEditDialog + + + Sound Object + + + + + + + + Name: + + + nameEdit + + + + + + + 15 + + + + + + + Filename: + + + fileEdit + + + + + + + + + + + + Browse... + + + + + + + + + Sound Type + + + + + + 3D + + + + + + + 2D + + + + + + + + + + Category + + + + + + Sound Effect + + + + + + + Music + + + + + + + + + + Infinite Loops + + + + + + + Stop When Hidden + + + + + + + Volume: + + + volumeSlider + + + + + + + 0 + + + 100 + + + Qt::Horizontal + + + + + + + Priority: + + + prioritySlider + + + + + + + 0 + + + 100 + + + Qt::Horizontal + + + + + + + Drop-off Radius: + + + dropOffEdit + + + + + + + 2 + + + 0.000000000000000 + + + 100000.000000000000000 + + + + + + + Max-Vol Radius: + + + maxVolEdit + + + + + + + 2 + + + 0.000000000000000 + + + 100000.000000000000000 + + + + + + + Trigger Radius: + + + triggerRadiusEdit + + + + + + + 2 + + + 0.000000000000000 + + + 100000.000000000000000 + + + + + + + + + Play... + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + diff --git a/Code/Tools/W3DViewQt/SphereEditDialog.cpp b/Code/Tools/W3DViewQt/SphereEditDialog.cpp new file mode 100644 index 000000000..d7e04de1e --- /dev/null +++ b/Code/Tools/W3DViewQt/SphereEditDialog.cpp @@ -0,0 +1,861 @@ +#include "SphereEditDialog.h" + +#include "ui_SphereEditDialog.h" + +#include "KeyframeTableUtils.h" +#include "OpacityVectorEditDialog.h" + +#include "aabox.h" +#include "assetmgr.h" +#include "euler.h" +#include "quat.h" +#include "shader.h" +#include "sphereobj.h" +#include "texture.h" +#include "vector3.h" +#include "wwmath.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr float kDegToRad = 3.14159265358979323846f / 180.0f; +constexpr float kRadToDeg = 180.0f / 3.14159265358979323846f; + +struct ShaderPreset { + const char *label; + ShaderClass shader; +}; + +ShaderPreset BuildPreset(const char *label, const ShaderClass &shader) +{ + ShaderPreset preset{label, shader}; + return preset; +} + +const ShaderPreset *ShaderPresets(int &count) +{ + static ShaderPreset presets[] = { + BuildPreset("Additive", ShaderClass::_PresetAdditiveShader), + BuildPreset("Alpha", ShaderClass::_PresetAlphaShader), + BuildPreset("Opaque", ShaderClass::_PresetOpaqueShader), + BuildPreset("Multiplicative", ShaderClass::_PresetMultiplicativeShader), + }; + + count = static_cast(sizeof(presets) / sizeof(presets[0])); + return presets; +} + +bool ShaderMatches(const ShaderClass &a, const ShaderClass &b) +{ + return a.Get_Bits() == b.Get_Bits(); +} + +void ConfigureKeyframeTable(QTableWidget *table) +{ + table->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); + table->setSelectionBehavior(QAbstractItemView::SelectRows); + table->setSelectionMode(QAbstractItemView::ExtendedSelection); + table->setEditTriggers(QAbstractItemView::NoEditTriggers); + table->setSortingEnabled(false); + table->setShowGrid(true); +} + +QVector> SortedRows(const QTableWidget *table) +{ + QVector> rows = GetKeyframeRows(table); + std::sort(rows.begin(), rows.end(), [](const QVector &a, const QVector &b) { + const double time_a = a.isEmpty() ? 0.0 : a[0]; + const double time_b = b.isEmpty() ? 0.0 : b[0]; + return time_a < time_b; + }); + return rows; +} + +std::optional PromptKeyTime(QWidget *parent, const QString &title) +{ + bool ok = false; + const double time = QInputDialog::getDouble(parent, title, "Time (0-1):", 0.0, 0.0, 1.0, 3, &ok); + if (!ok) { + return std::nullopt; + } + return time; +} + +AlphaVectorStruct BuildAlphaVector(float intensity, float y_deg, float z_deg) +{ + Matrix3 rot_mat(true); + rot_mat.Rotate_Y(y_deg * kDegToRad); + rot_mat.Rotate_Z(z_deg * kDegToRad); + + AlphaVectorStruct value; + value.intensity = intensity; + value.angle = Build_Quaternion(rot_mat); + return value; +} + +void AlphaVectorAngles(const AlphaVectorStruct &value, float &y_deg, float &z_deg) +{ + Matrix3D rotation = Build_Matrix3D(value.angle); + EulerAnglesClass euler(rotation, EulerOrderXYZr); + y_deg = static_cast(euler.Get_Angle(1) * kRadToDeg); + z_deg = static_cast(euler.Get_Angle(2) * kRadToDeg); + y_deg = static_cast(WWMath::Wrap(y_deg, 0.0f, 360.0f)); + z_deg = static_cast(WWMath::Wrap(z_deg, 0.0f, 360.0f)); +} + +SphereColorChannelClass BuildColorChannel(QTableWidget *table, const Vector3 &fallback) +{ + SphereColorChannelClass channel; + channel.Reset(); + + const QVector> rows = SortedRows(table); + if (rows.isEmpty()) { + channel.Add_Key(fallback, 0.0f); + return channel; + } + + for (const QVector &row : rows) { + if (row.size() < 4) { + continue; + } + channel.Add_Key(Vector3(row[1], row[2], row[3]), static_cast(row[0])); + } + + return channel; +} + +SphereAlphaChannelClass BuildAlphaChannel(QTableWidget *table, float fallback) +{ + SphereAlphaChannelClass channel; + channel.Reset(); + + const QVector> rows = SortedRows(table); + if (rows.isEmpty()) { + channel.Add_Key(fallback, 0.0f); + return channel; + } + + for (const QVector &row : rows) { + if (row.size() < 2) { + continue; + } + channel.Add_Key(static_cast(row[1]), static_cast(row[0])); + } + + return channel; +} + +SphereVectorChannelClass BuildVectorChannel(QTableWidget *table, const AlphaVectorStruct &fallback) +{ + SphereVectorChannelClass channel; + channel.Reset(); + + const QVector> rows = SortedRows(table); + if (rows.isEmpty()) { + channel.Add_Key(fallback, 0.0f); + return channel; + } + + for (const QVector &row : rows) { + if (row.size() < 4) { + continue; + } + const float intensity = static_cast(row[1]); + const float y_deg = static_cast(row[2]); + const float z_deg = static_cast(row[3]); + channel.Add_Key(BuildAlphaVector(intensity, y_deg, z_deg), static_cast(row[0])); + } + + return channel; +} + +SphereScaleChannelClass BuildScaleChannel(QTableWidget *table, const Vector3 &fallback) +{ + SphereScaleChannelClass channel; + channel.Reset(); + + const QVector> rows = SortedRows(table); + if (rows.isEmpty()) { + channel.Add_Key(fallback, 0.0f); + return channel; + } + + for (const QVector &row : rows) { + if (row.size() < 4) { + continue; + } + channel.Add_Key(Vector3(row[1], row[2], row[3]), static_cast(row[0])); + } + + return channel; +} +} + +SphereEditDialog::SphereEditDialog(SphereRenderObjClass *sphere, QWidget *parent) + : QDialog(parent), + _ui(new Ui::SphereEditDialog) +{ + const bool is_new_sphere = sphere == nullptr; + _ui->setupUi(this); + + _nameEdit = _ui->nameEdit; + _textureEdit = _ui->textureEdit; + _lifetimeSpin = _ui->lifetimeSpin; + _shaderCombo = _ui->shaderCombo; + _cameraAlignCheck = _ui->cameraAlignCheck; + _loopCheck = _ui->loopCheck; + _colorKeysTable = _ui->colorKeysTable; + _alphaKeysTable = _ui->alphaKeysTable; + _vectorKeysTable = _ui->vectorKeysTable; + _useVectorCheck = _ui->useVectorCheck; + _invertVectorCheck = _ui->invertVectorCheck; + _sizeXSpin = _ui->sizeXSpin; + _sizeYSpin = _ui->sizeYSpin; + _sizeZSpin = _ui->sizeZSpin; + _scaleKeysTable = _ui->scaleKeysTable; + + ConfigureKeyframeTable(_colorKeysTable); + ConfigureKeyframeTable(_alphaKeysTable); + ConfigureKeyframeTable(_vectorKeysTable); + ConfigureKeyframeTable(_scaleKeysTable); + + if (sphere) { + _sphere = sphere; + _sphere->Add_Ref(); + } else { + _sphere = new SphereRenderObjClass; + _sphere->Set_Name("Sphere"); + } + + if (_sphere && _sphere->Get_Name()) { + _oldName = QString::fromLatin1(_sphere->Get_Name()); + } + _registeredName = is_new_sphere ? QString() : _oldName; + _initialApplyRequired = is_new_sphere; + if (_sphere) { + _lastAppliedSphere = new SphereRenderObjClass(*_sphere); + } + + int preset_count = 0; + const ShaderPreset *presets = ShaderPresets(preset_count); + for (int i = 0; i < preset_count; ++i) { + _shaderCombo->addItem(presets[i].label, i); + } + + const QVector color_specs = { + {0.0, 1.0, 3}, + {0.0, 1.0, 3}, + {0.0, 1.0, 3}, + {0.0, 1.0, 3}, + }; + const QVector alpha_specs = { + {0.0, 1.0, 3}, + {0.0, 1.0, 3}, + }; + const QVector vector_specs = { + {0.0, 1.0, 3}, + {0.0, 10.0, 2}, + {0.0, 179.0, 0}, + {0.0, 179.0, 0}, + }; + const QVector scale_specs = { + {0.0, 1.0, 3}, + {0.0, 10000.0, 3}, + {0.0, 10000.0, 3}, + {0.0, 10000.0, 3}, + }; + connect(_ui->browseButton, &QPushButton::clicked, this, &SphereEditDialog::browseTexture); + connect(_ui->buttonBox, &QDialogButtonBox::accepted, this, &SphereEditDialog::accept); + connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &SphereEditDialog::reject); + if (QPushButton *apply_button = _ui->buttonBox->button(QDialogButtonBox::Apply)) { + connect(apply_button, &QPushButton::clicked, this, &SphereEditDialog::apply); + } + + auto *color_add = _ui->colorAddButton; + auto *color_remove = _ui->colorRemoveButton; + auto *color_sort = _ui->colorSortButton; + auto *alpha_add = _ui->alphaAddButton; + auto *alpha_remove = _ui->alphaRemoveButton; + auto *alpha_sort = _ui->alphaSortButton; + auto *vector_add = _ui->vectorAddButton; + auto *vector_edit = _ui->vectorEditButton; + auto *vector_remove = _ui->vectorRemoveButton; + auto *vector_sort = _ui->vectorSortButton; + auto *scale_add = _ui->scaleAddButton; + auto *scale_remove = _ui->scaleRemoveButton; + auto *scale_sort = _ui->scaleSortButton; + + connect(_useVectorCheck, &QCheckBox::toggled, this, [this]() { + const bool enabled = _useVectorCheck && _useVectorCheck->isChecked(); + if (_vectorKeysTable) { + _vectorKeysTable->setEnabled(enabled); + } + if (_invertVectorCheck) { + _invertVectorCheck->setEnabled(enabled); + } + }); + + connect(color_add, &QPushButton::clicked, this, [this, color_specs]() { + const auto time = PromptKeyTime(this, "Add Color Key"); + if (!time) { + return; + } + SphereColorChannelClass channel = BuildColorChannel(_colorKeysTable, _sphere->Get_Color()); + const Vector3 value = channel.Evaluate(static_cast(*time)); + AddKeyframeRow(_colorKeysTable, {*time, value.X, value.Y, value.Z}, color_specs); + SortKeyframeRows(_colorKeysTable, color_specs); + connectEditorSignals(); + editorChanged(); + }); + connect(color_remove, &QPushButton::clicked, this, [this]() { + RemoveSelectedKeyframeRows(_colorKeysTable); + connectEditorSignals(); + editorChanged(); + }); + connect(color_sort, &QPushButton::clicked, this, [this, color_specs]() { + SortKeyframeRows(_colorKeysTable, color_specs); + connectEditorSignals(); + editorChanged(); + }); + + connect(alpha_add, &QPushButton::clicked, this, [this, alpha_specs]() { + const auto time = PromptKeyTime(this, "Add Opacity Key"); + if (!time) { + return; + } + SphereAlphaChannelClass channel = BuildAlphaChannel(_alphaKeysTable, _sphere->Get_Alpha()); + const float value = channel.Evaluate(static_cast(*time)); + AddKeyframeRow(_alphaKeysTable, {*time, value}, alpha_specs); + SortKeyframeRows(_alphaKeysTable, alpha_specs); + connectEditorSignals(); + editorChanged(); + }); + connect(alpha_remove, &QPushButton::clicked, this, [this]() { + RemoveSelectedKeyframeRows(_alphaKeysTable); + connectEditorSignals(); + editorChanged(); + }); + connect(alpha_sort, &QPushButton::clicked, this, [this, alpha_specs]() { + SortKeyframeRows(_alphaKeysTable, alpha_specs); + connectEditorSignals(); + editorChanged(); + }); + + connect(vector_add, &QPushButton::clicked, this, [this, vector_specs]() { + const auto time = PromptKeyTime(this, "Add Opacity Vector Key"); + if (!time) { + return; + } + SphereVectorChannelClass channel = BuildVectorChannel(_vectorKeysTable, _sphere->Get_Vector()); + const AlphaVectorStruct value = channel.Evaluate(static_cast(*time)); + float y_deg = 0.0f; + float z_deg = 0.0f; + AlphaVectorAngles(value, y_deg, z_deg); + AddKeyframeRow(_vectorKeysTable, {*time, value.intensity, y_deg, z_deg}, vector_specs); + SortKeyframeRows(_vectorKeysTable, vector_specs); + connectEditorSignals(); + editorChanged(); + }); + connect(vector_edit, &QPushButton::clicked, this, [this, vector_specs]() { + if (!_vectorKeysTable) { + return; + } + const int row = _vectorKeysTable->currentRow(); + if (row < 0) { + QMessageBox::information(this, "Opacity Vector", "Select a vector key to edit."); + return; + } + const QVector> rows = GetKeyframeRows(_vectorKeysTable); + if (row >= rows.size() || rows[row].size() < 4) { + return; + } + const double intensity = rows[row][1]; + const double y_deg = rows[row][2]; + const double z_deg = rows[row][3]; + OpacityVectorEditDialog dialog(BuildAlphaVector(static_cast(intensity), + static_cast(y_deg), + static_cast(z_deg)), + this); + if (dialog.exec() != QDialog::Accepted) { + return; + } + const AlphaVectorStruct updated = dialog.value(); + float new_y = 0.0f; + float new_z = 0.0f; + AlphaVectorAngles(updated, new_y, new_z); + if (auto *spin = qobject_cast(_vectorKeysTable->cellWidget(row, 1))) { + spin->setValue(updated.intensity); + } + if (auto *spin = qobject_cast(_vectorKeysTable->cellWidget(row, 2))) { + spin->setValue(new_y); + } + if (auto *spin = qobject_cast(_vectorKeysTable->cellWidget(row, 3))) { + spin->setValue(new_z); + } + }); + connect(vector_remove, &QPushButton::clicked, this, [this]() { + RemoveSelectedKeyframeRows(_vectorKeysTable); + connectEditorSignals(); + editorChanged(); + }); + connect(vector_sort, &QPushButton::clicked, this, [this, vector_specs]() { + SortKeyframeRows(_vectorKeysTable, vector_specs); + connectEditorSignals(); + editorChanged(); + }); + + connect(scale_add, &QPushButton::clicked, this, [this, scale_specs]() { + const auto time = PromptKeyTime(this, "Add Scale Key"); + if (!time) { + return; + } + SphereScaleChannelClass channel = BuildScaleChannel(_scaleKeysTable, _sphere->Get_Scale()); + const Vector3 value = channel.Evaluate(static_cast(*time)); + AddKeyframeRow(_scaleKeysTable, {*time, value.X, value.Y, value.Z}, scale_specs); + SortKeyframeRows(_scaleKeysTable, scale_specs); + connectEditorSignals(); + editorChanged(); + }); + connect(scale_remove, &QPushButton::clicked, this, [this]() { + RemoveSelectedKeyframeRows(_scaleKeysTable); + connectEditorSignals(); + editorChanged(); + }); + connect(scale_sort, &QPushButton::clicked, this, [this, scale_specs]() { + SortKeyframeRows(_scaleKeysTable, scale_specs); + connectEditorSignals(); + editorChanged(); + }); + + loadFromSphere(); + connectEditorSignals(); + updateApplyButton(); +} +SphereEditDialog::~SphereEditDialog() +{ + if (_lastAppliedSphere) { + _lastAppliedSphere->Release_Ref(); + _lastAppliedSphere = nullptr; + } + if (_sphere) { + _sphere->Release_Ref(); + _sphere = nullptr; + } + delete _ui; +} + +SphereRenderObjClass *SphereEditDialog::sphere() const +{ + if (_sphere) { + _sphere->Add_Ref(); + } + return _sphere; +} + +QString SphereEditDialog::oldName() const +{ + return _oldName; +} + +QString SphereEditDialog::registeredName() const +{ + return _registeredName; +} + +void SphereEditDialog::setApplyHandler(ApplyHandler handler, + const QString ®isteredName, + bool initialApplyRequired) +{ + _applyHandler = std::move(handler); + _registeredName = registeredName; + _initialApplyRequired = initialApplyRequired; + updateApplyButton(); +} + +void SphereEditDialog::connectEditorSignals() +{ + if (_nameEdit) { + connect(_nameEdit, + &QLineEdit::textChanged, + this, + &SphereEditDialog::editorChanged, + Qt::UniqueConnection); + } + if (_textureEdit) { + connect(_textureEdit, + &QLineEdit::textChanged, + this, + &SphereEditDialog::editorChanged, + Qt::UniqueConnection); + } + if (_shaderCombo) { + connect(_shaderCombo, + qOverload(&QComboBox::currentIndexChanged), + this, + &SphereEditDialog::editorChanged, + Qt::UniqueConnection); + } + for (QCheckBox *check_box : {_cameraAlignCheck, _loopCheck, _useVectorCheck, _invertVectorCheck}) { + if (check_box) { + connect(check_box, + &QCheckBox::toggled, + this, + &SphereEditDialog::editorChanged, + Qt::UniqueConnection); + } + } + const auto spin_boxes = findChildren(); + for (QDoubleSpinBox *spin_box : spin_boxes) { + connect(spin_box, + qOverload(&QDoubleSpinBox::valueChanged), + this, + &SphereEditDialog::editorChanged, + Qt::UniqueConnection); + } +} + +bool SphereEditDialog::updateSphereFromUi(bool showWarnings) +{ + if (!_sphere) { + return false; + } + + const QString name = _nameEdit ? _nameEdit->text().trimmed() : QString(); + if (name.isEmpty()) { + if (showWarnings) { + QMessageBox::warning(this, "Sphere", "Invalid sphere name. Please enter a new name."); + } + return !showWarnings; + } + + TextureClass *texture = nullptr; + bool can_update_texture = false; + const QString texture_path = _textureEdit ? _textureEdit->text().trimmed() : QString(); + if (texture_path.isEmpty()) { + can_update_texture = true; + } else { + const QString file_name_only = QFileInfo(texture_path).fileName(); + if (file_name_only.isEmpty()) { + if (showWarnings) { + QMessageBox::warning(this, "Sphere", "Invalid texture filename."); + } + return !showWarnings; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + if (showWarnings) { + QMessageBox::warning(this, "Sphere", "WW3D asset manager is not available."); + return false; + } + } else { + const QByteArray texture_bytes = file_name_only.toLatin1(); + texture = asset_manager->Get_Texture(texture_bytes.constData()); + can_update_texture = true; + } + } + + if (can_update_texture) { + _sphere->Set_Texture(texture); + if (texture) { + texture->Release_Ref(); + } + } + + int preset_count = 0; + const ShaderPreset *presets = ShaderPresets(preset_count); + const int shader_index = _shaderCombo ? _shaderCombo->currentData().toInt() : -1; + if (shader_index >= 0 && shader_index < preset_count) { + ShaderClass shader = presets[shader_index].shader; + _sphere->Set_Shader(shader); + } else if (_lastAppliedSphere) { + ShaderClass shader = _lastAppliedSphere->Get_Shader(); + _sphere->Set_Shader(shader); + } + + const float lifetime = _lifetimeSpin ? static_cast(_lifetimeSpin->value()) : 0.0f; + _sphere->Set_Animation_Duration(lifetime); + + if (_cameraAlignCheck) { + _sphere->Set_Flag(SphereRenderObjClass::USE_CAMERA_ALIGN, _cameraAlignCheck->isChecked()); + } + if (_loopCheck) { + _sphere->Set_Flag(SphereRenderObjClass::USE_ANIMATION_LOOP, _loopCheck->isChecked()); + } + + const SphereColorChannelClass color_channel = BuildColorChannel(_colorKeysTable, _sphere->Get_Color()); + const SphereAlphaChannelClass alpha_channel = BuildAlphaChannel(_alphaKeysTable, _sphere->Get_Alpha()); + const SphereVectorChannelClass vector_channel = BuildVectorChannel(_vectorKeysTable, _sphere->Get_Vector()); + _sphere->Set_Color_Channel(color_channel); + _sphere->Set_Alpha_Channel(alpha_channel); + _sphere->Set_Vector_Channel(vector_channel); + + if (_useVectorCheck) { + _sphere->Set_Flag(SphereRenderObjClass::USE_ALPHA_VECTOR, _useVectorCheck->isChecked()); + } + if (_invertVectorCheck) { + _sphere->Set_Flag(SphereRenderObjClass::USE_INVERSE_ALPHA, _invertVectorCheck->isChecked()); + } + + const float x = _sizeXSpin ? static_cast(_sizeXSpin->value()) : 0.0f; + const float y = _sizeYSpin ? static_cast(_sizeYSpin->value()) : 0.0f; + const float z = _sizeZSpin ? static_cast(_sizeZSpin->value()) : 0.0f; + _sphere->Set_Extent(Vector3(x, y, z)); + + const SphereScaleChannelClass scale_channel = BuildScaleChannel(_scaleKeysTable, _sphere->Get_Scale()); + _sphere->Set_Scale_Channel(scale_channel); + + const QByteArray name_bytes = name.toLatin1(); + _sphere->Set_Name(name_bytes.constData()); + _sphere->Restart_Animation(); + + return true; +} + +bool SphereEditDialog::commitPendingChanges() +{ + const bool has_pending_changes = _initialApplyRequired || _dirty; + if (!updateSphereFromUi(true)) { + return false; + } + + if (!has_pending_changes) { + return true; + } + + if (_applyHandler && !_applyHandler(*_sphere, _registeredName)) { + return false; + } + + if (const char *name = _sphere->Get_Name()) { + _registeredName = QString::fromLatin1(name); + } + if (_lastAppliedSphere) { + *_lastAppliedSphere = *_sphere; + } + _dirty = false; + _initialApplyRequired = false; + updateApplyButton(); + return true; +} + +void SphereEditDialog::apply() +{ + commitPendingChanges(); +} + +void SphereEditDialog::accept() +{ + if (!commitPendingChanges()) { + return; + } + + QDialog::accept(); +} + +void SphereEditDialog::reject() +{ + if (_sphere && _lastAppliedSphere) { + *_sphere = *_lastAppliedSphere; + _sphere->Restart_Animation(); + } + QDialog::reject(); +} + +void SphereEditDialog::editorChanged() +{ + _dirty = true; + updateSphereFromUi(false); + updateApplyButton(); +} + +void SphereEditDialog::updateApplyButton() +{ + if (!_ui || !_ui->buttonBox) { + return; + } + if (QPushButton *apply_button = _ui->buttonBox->button(QDialogButtonBox::Apply)) { + apply_button->setEnabled(_initialApplyRequired || _dirty); + } +} + +void SphereEditDialog::browseTexture() +{ + const QString start = _textureEdit ? _textureEdit->text() : QString(); + const QString path = QFileDialog::getOpenFileName( + this, + "Select Texture", + start, + "Texture Files (*.tga);;All Files (*.*)"); + if (!path.isEmpty() && _textureEdit) { + _textureEdit->setText(path); + } +} + +void SphereEditDialog::loadFromSphere() +{ + if (!_sphere) { + return; + } + + if (_nameEdit) { + const char *name = _sphere->Get_Name(); + _nameEdit->setText(name ? QString::fromLatin1(name) : QString()); + } + + if (_textureEdit) { + TextureClass *texture = _sphere->Peek_Texture(); + if (texture) { + const StringClass &name = texture->Get_Texture_Name(); + const QByteArray name_bytes(name.Peek_Buffer(), static_cast(name.Get_Length())); + _textureEdit->setText(QString::fromLatin1(name_bytes)); + } + } + + if (_lifetimeSpin) { + _lifetimeSpin->setValue(_sphere->Get_Animation_Duration()); + } + + if (_shaderCombo) { + int shader_index = findShaderIndex(); + if (shader_index < 0) { + _shaderCombo->addItem("Custom (preserved)", -1); + shader_index = _shaderCombo->count() - 1; + } + _shaderCombo->setCurrentIndex(shader_index); + } + + const unsigned int flags = _sphere->Get_Flags(); + if (_cameraAlignCheck) { + _cameraAlignCheck->setChecked((flags & SphereRenderObjClass::USE_CAMERA_ALIGN) != 0); + } + if (_loopCheck) { + _loopCheck->setChecked((flags & SphereRenderObjClass::USE_ANIMATION_LOOP) != 0); + } + + if (_useVectorCheck) { + _useVectorCheck->setChecked((flags & SphereRenderObjClass::USE_ALPHA_VECTOR) != 0); + } + if (_invertVectorCheck) { + _invertVectorCheck->setChecked((flags & SphereRenderObjClass::USE_INVERSE_ALPHA) != 0); + } + if (_vectorKeysTable) { + _vectorKeysTable->setEnabled(_useVectorCheck && _useVectorCheck->isChecked()); + } + if (_invertVectorCheck) { + _invertVectorCheck->setEnabled(_useVectorCheck && _useVectorCheck->isChecked()); + } + + AABoxClass box; + _sphere->Get_Obj_Space_Bounding_Box(box); + if (_sizeXSpin) { + _sizeXSpin->setValue(box.Extent.X); + } + if (_sizeYSpin) { + _sizeYSpin->setValue(box.Extent.Y); + } + if (_sizeZSpin) { + _sizeZSpin->setValue(box.Extent.Z); + } + + SphereColorChannelClass color_channel = _sphere->Get_Color_Channel(); + if (color_channel.Get_Key_Count() == 0) { + color_channel.Add_Key(_sphere->Get_Color(), 0.0f); + } + QVector> color_rows; + for (int i = 0; i < color_channel.Get_Key_Count(); ++i) { + const auto &key = color_channel.Get_Key(i); + const Vector3 value = key.Get_Value(); + color_rows.push_back({key.Get_Time(), value.X, value.Y, value.Z}); + } + SetKeyframeRows(_colorKeysTable, + color_rows, + QVector{{0.0, 1.0, 3}, + {0.0, 1.0, 3}, + {0.0, 1.0, 3}, + {0.0, 1.0, 3}}); + + SphereAlphaChannelClass alpha_channel = _sphere->Get_Alpha_Channel(); + if (alpha_channel.Get_Key_Count() == 0) { + alpha_channel.Add_Key(_sphere->Get_Alpha(), 0.0f); + } + QVector> alpha_rows; + for (int i = 0; i < alpha_channel.Get_Key_Count(); ++i) { + const auto &key = alpha_channel.Get_Key(i); + alpha_rows.push_back({key.Get_Time(), key.Get_Value()}); + } + SetKeyframeRows(_alphaKeysTable, + alpha_rows, + QVector{{0.0, 1.0, 3}, {0.0, 1.0, 3}}); + + SphereVectorChannelClass vector_channel = _sphere->Get_Vector_Channel(); + if (vector_channel.Get_Key_Count() == 0) { + vector_channel.Add_Key(_sphere->Get_Vector(), 0.0f); + } + QVector> vector_rows; + for (int i = 0; i < vector_channel.Get_Key_Count(); ++i) { + const auto &key = vector_channel.Get_Key(i); + const AlphaVectorStruct value = key.Get_Value(); + float y_deg = 0.0f; + float z_deg = 0.0f; + AlphaVectorAngles(value, y_deg, z_deg); + vector_rows.push_back({key.Get_Time(), value.intensity, y_deg, z_deg}); + } + SetKeyframeRows(_vectorKeysTable, + vector_rows, + QVector{{0.0, 1.0, 3}, + {0.0, 10.0, 2}, + {0.0, 179.0, 0}, + {0.0, 179.0, 0}}); + + SphereScaleChannelClass scale_channel = _sphere->Get_Scale_Channel(); + if (scale_channel.Get_Key_Count() == 0) { + scale_channel.Add_Key(_sphere->Get_Scale(), 0.0f); + } + QVector> scale_rows; + for (int i = 0; i < scale_channel.Get_Key_Count(); ++i) { + const auto &key = scale_channel.Get_Key(i); + const Vector3 value = key.Get_Value(); + scale_rows.push_back({key.Get_Time(), value.X, value.Y, value.Z}); + } + SetKeyframeRows(_scaleKeysTable, + scale_rows, + QVector{{0.0, 1.0, 3}, + {0.0, 10000.0, 3}, + {0.0, 10000.0, 3}, + {0.0, 10000.0, 3}}); +} + +int SphereEditDialog::findShaderIndex() const +{ + if (!_sphere) { + return -1; + } + + int preset_count = 0; + const ShaderPreset *presets = ShaderPresets(preset_count); + const ShaderClass &shader = _sphere->Get_Shader(); + for (int index = 0; index < preset_count; ++index) { + if (ShaderMatches(presets[index].shader, shader)) { + return index; + } + } + + return -1; +} diff --git a/Code/Tools/W3DViewQt/SphereEditDialog.h b/Code/Tools/W3DViewQt/SphereEditDialog.h new file mode 100644 index 000000000..ac0b16655 --- /dev/null +++ b/Code/Tools/W3DViewQt/SphereEditDialog.h @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include + +class QCheckBox; +class QComboBox; +class QDoubleSpinBox; +class QLineEdit; +class QTableWidget; +class SphereRenderObjClass; + +namespace Ui { +class SphereEditDialog; +} + +class SphereEditDialog final : public QDialog +{ + Q_OBJECT + +public: + using ApplyHandler = std::function; + + explicit SphereEditDialog(SphereRenderObjClass *sphere, QWidget *parent = nullptr); + ~SphereEditDialog() override; + + SphereRenderObjClass *sphere() const; + QString oldName() const; + QString registeredName() const; + void setApplyHandler(ApplyHandler handler, + const QString ®isteredName, + bool initialApplyRequired = false); + +protected: + void accept() override; + void reject() override; + +private slots: + void apply(); + void browseTexture(); + void editorChanged(); + +private: + void connectEditorSignals(); + void loadFromSphere(); + bool updateSphereFromUi(bool showWarnings); + bool commitPendingChanges(); + void updateApplyButton(); + int findShaderIndex() const; + + Ui::SphereEditDialog *_ui = nullptr; + SphereRenderObjClass *_sphere = nullptr; + SphereRenderObjClass *_lastAppliedSphere = nullptr; + QString _oldName; + QString _registeredName; + ApplyHandler _applyHandler; + bool _dirty = false; + bool _initialApplyRequired = false; + + QLineEdit *_nameEdit = nullptr; + QLineEdit *_textureEdit = nullptr; + QDoubleSpinBox *_lifetimeSpin = nullptr; + QComboBox *_shaderCombo = nullptr; + QCheckBox *_cameraAlignCheck = nullptr; + QCheckBox *_loopCheck = nullptr; + + QTableWidget *_colorKeysTable = nullptr; + QTableWidget *_alphaKeysTable = nullptr; + QTableWidget *_vectorKeysTable = nullptr; + QCheckBox *_useVectorCheck = nullptr; + QCheckBox *_invertVectorCheck = nullptr; + + QDoubleSpinBox *_sizeXSpin = nullptr; + QDoubleSpinBox *_sizeYSpin = nullptr; + QDoubleSpinBox *_sizeZSpin = nullptr; + QTableWidget *_scaleKeysTable = nullptr; +}; diff --git a/Code/Tools/W3DViewQt/SphereEditDialog.ui b/Code/Tools/W3DViewQt/SphereEditDialog.ui new file mode 100644 index 000000000..72b9609b1 --- /dev/null +++ b/Code/Tools/W3DViewQt/SphereEditDialog.ui @@ -0,0 +1,548 @@ + + + SphereEditDialog + + + Sphere Properties + + + + + + 0 + + + + General + + + + + + Name: + + + nameEdit + + + + + + + 31 + + + + + + + Texture: + + + textureEdit + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + Browse... + + + + + + + + + + Shader: + + + shaderCombo + + + + + + + + + + Lifetime: + + + lifetimeSpin + + + + + + + 2 + + + 1000.000000000000000 + + + + + + + Flags: + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Camera Align + + + + + + + Looping + + + + + + + + + + + Color + + + + + + Color Keys + + + + + + QAbstractItemView::NoEditTriggers + + + QAbstractItemView::ExtendedSelection + + + QAbstractItemView::SelectRows + + + false + + + + Time + + + + + R + + + + + G + + + + + B + + + + + + + + + + Add + + + + + + + Remove + + + + + + + Sort + + + + + + + + + + + + Opacity Keys + + + + + + QAbstractItemView::NoEditTriggers + + + QAbstractItemView::ExtendedSelection + + + QAbstractItemView::SelectRows + + + false + + + + Time + + + + + Alpha + + + + + + + + + + Add + + + + + + + Remove + + + + + + + Sort + + + + + + + + + + + + Opacity Vector Keys + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Use Opacity Vector + + + + + + + Invert + + + + + + + + + + QAbstractItemView::NoEditTriggers + + + QAbstractItemView::ExtendedSelection + + + QAbstractItemView::SelectRows + + + false + + + + Time + + + + + Intensity + + + + + Y + + + + + Z + + + + + + + + + + Add + + + + + + + Edit + + + + + + + Remove + + + + + + + Sort + + + + + + + + + + + + + Size + + + + + + + + Extent X: + + + sizeXSpin + + + + + + + 2 + + + 10000.000000000000000 + + + + + + + Extent Y: + + + sizeYSpin + + + + + + + 2 + + + 10000.000000000000000 + + + + + + + Extent Z: + + + sizeZSpin + + + + + + + 2 + + + 10000.000000000000000 + + + + + + + + + Scale Keys + + + + + + QAbstractItemView::NoEditTriggers + + + QAbstractItemView::ExtendedSelection + + + QAbstractItemView::SelectRows + + + false + + + + Time + + + + + X + + + + + Y + + + + + Z + + + + + + + + + + Add + + + + + + + Remove + + + + + + + Sort + + + + + + + + + + + + + + + + QDialogButtonBox::Apply|QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + diff --git a/Code/Tools/W3DViewQt/TexturePathDialog.cpp b/Code/Tools/W3DViewQt/TexturePathDialog.cpp new file mode 100644 index 000000000..c79692c82 --- /dev/null +++ b/Code/Tools/W3DViewQt/TexturePathDialog.cpp @@ -0,0 +1,55 @@ +#include "TexturePathDialog.h" + +#include "ui_TexturePathDialog.h" + +#include +#include +#include +#include + +TexturePathDialog::TexturePathDialog(const QString &path1, const QString &path2, QWidget *parent) + : QDialog(parent) + , _ui(new Ui::TexturePathDialog) +{ + _ui->setupUi(this); + _ui->path1LineEdit->setText(QDir::toNativeSeparators(path1)); + _ui->path2LineEdit->setText(QDir::toNativeSeparators(path2)); + + connect(_ui->path1BrowseButton, &QPushButton::clicked, this, &TexturePathDialog::browsePath1); + connect(_ui->path2BrowseButton, &QPushButton::clicked, this, &TexturePathDialog::browsePath2); + connect(_ui->buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(_ui->buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); +} + +TexturePathDialog::~TexturePathDialog() +{ + delete _ui; +} + +QString TexturePathDialog::path1() const +{ + return _ui->path1LineEdit->text().trimmed(); +} + +QString TexturePathDialog::path2() const +{ + return _ui->path2LineEdit->text().trimmed(); +} + +void TexturePathDialog::browsePath1() +{ + const QString start = _ui->path1LineEdit->text(); + const QString dir = QFileDialog::getExistingDirectory(this, "Texture Path 1", start); + if (!dir.isEmpty()) { + _ui->path1LineEdit->setText(QDir::toNativeSeparators(dir)); + } +} + +void TexturePathDialog::browsePath2() +{ + const QString start = _ui->path2LineEdit->text(); + const QString dir = QFileDialog::getExistingDirectory(this, "Texture Path 2", start); + if (!dir.isEmpty()) { + _ui->path2LineEdit->setText(QDir::toNativeSeparators(dir)); + } +} diff --git a/Code/Tools/W3DViewQt/TexturePathDialog.h b/Code/Tools/W3DViewQt/TexturePathDialog.h new file mode 100644 index 000000000..fdebcb2db --- /dev/null +++ b/Code/Tools/W3DViewQt/TexturePathDialog.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +namespace Ui { +class TexturePathDialog; +} + +class TexturePathDialog final : public QDialog +{ + Q_OBJECT + +public: + explicit TexturePathDialog(const QString &path1, const QString &path2, QWidget *parent = nullptr); + ~TexturePathDialog() override; + + QString path1() const; + QString path2() const; + +private slots: + void browsePath1(); + void browsePath2(); + +private: + Ui::TexturePathDialog *_ui = nullptr; +}; diff --git a/Code/Tools/W3DViewQt/TexturePathDialog.ui b/Code/Tools/W3DViewQt/TexturePathDialog.ui new file mode 100644 index 000000000..adec4c754 --- /dev/null +++ b/Code/Tools/W3DViewQt/TexturePathDialog.ui @@ -0,0 +1,68 @@ + + + TexturePathDialog + + + Texture Paths + + + + + + Path &1: + + + path1LineEdit + + + + + + + + + + + + Browse... + + + + + + + + + Path &2: + + + path2LineEdit + + + + + + + + + + + + Browse... + + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + diff --git a/Code/Tools/W3DViewQt/W3DExportUtils.cpp b/Code/Tools/W3DViewQt/W3DExportUtils.cpp new file mode 100644 index 000000000..f0a06b79d --- /dev/null +++ b/Code/Tools/W3DViewQt/W3DExportUtils.cpp @@ -0,0 +1,268 @@ +#include "W3DExportUtils.h" + +#include "chunkio.h" +#include "rawfile.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace +{ +bool Fail(QString *error_message, const QString &message) +{ + if (error_message) { + *error_message = message; + } + return false; +} + +QByteArray NativeFileName(const QString &path) +{ + return QFile::encodeName(QDir::toNativeSeparators(path)); +} + +class TemporaryFileCleanup +{ +public: + explicit TemporaryFileCleanup(QString path) + : path_(std::move(path)) + { + } + + ~TemporaryFileCleanup() + { + if (!path_.isEmpty()) { + QFile::remove(path_); + } + } + + TemporaryFileCleanup(const TemporaryFileCleanup &) = delete; + TemporaryFileCleanup &operator=(const TemporaryFileCleanup &) = delete; + +private: + QString path_; +}; + +bool ValidateStagedFile(const QString &path, + std::uint32_t expected_top_level_chunk, + QString *error_message) +{ + const QByteArray native_path = NativeFileName(path); + RawFileClass file(native_path.constData()); + if (!file.Open(FileClass::READ)) { + return Fail(error_message, + QStringLiteral("Could not reopen the temporary W3D export for validation.")); + } + + const auto validation_failure = [&](const QString &message) { + file.Close(); + return Fail(error_message, message); + }; + + const int file_size = file.Size(); + if (file_size < static_cast(sizeof(ChunkHeader))) { + return validation_failure( + QStringLiteral("The temporary W3D export is too small to contain a chunk header.")); + } + + ChunkLoadClass chunk_load(&file); + if (!chunk_load.Open_Chunk()) { + return validation_failure( + QStringLiteral("The temporary W3D export does not contain a readable top-level chunk.")); + } + + const std::uint32_t actual_top_level_chunk = chunk_load.Cur_Chunk_ID(); + if (actual_top_level_chunk != expected_top_level_chunk) { + return validation_failure( + QStringLiteral("The temporary W3D export has top-level chunk 0x%1; expected 0x%2.") + .arg(static_cast(actual_top_level_chunk), 8, 16, QLatin1Char('0')) + .arg(static_cast(expected_top_level_chunk), 8, 16, QLatin1Char('0'))); + } + + const quint64 declared_file_size = + static_cast(chunk_load.Cur_Chunk_Length()) + sizeof(ChunkHeader); + if (declared_file_size != static_cast(file_size)) { + return validation_failure( + QStringLiteral("The temporary W3D export's top-level chunk does not span the entire file.")); + } + + if (!chunk_load.Close_Chunk()) { + return validation_failure( + QStringLiteral("The temporary W3D export's top-level chunk could not be closed.")); + } + + if (file.Tell() != file_size) { + return validation_failure( + QStringLiteral("The temporary W3D export did not end at the top-level chunk boundary.")); + } + + file.Close(); + return true; +} +} + +namespace W3DExportUtils +{ +bool SaveChunkFileAtomically(const QString &target_path, + std::uint32_t expected_top_level_chunk, + const ChunkWriter &writer, + QString *error_message) +{ + if (error_message) { + error_message->clear(); + } + + if (target_path.isEmpty()) { + return Fail(error_message, QStringLiteral("No W3D export filename was provided.")); + } + if (!writer) { + return Fail(error_message, QStringLiteral("No W3D export writer was provided.")); + } + + const QFileInfo target_info(target_path); + if (target_info.fileName().isEmpty()) { + return Fail(error_message, + QStringLiteral("The W3D export path does not contain a filename.")); + } + + const QString absolute_target_path = target_info.absoluteFilePath(); + const QDir target_directory = target_info.absoluteDir(); + if (!target_directory.exists()) { + return Fail(error_message, + QStringLiteral("The W3D export directory does not exist: %1") + .arg(QDir::toNativeSeparators(target_directory.absolutePath()))); + } + + const QString temporary_template = target_directory.filePath( + QStringLiteral(".%1.w3dview-XXXXXX.tmp").arg(target_info.fileName())); + QString staged_path; + { + QTemporaryFile staged_file(temporary_template); + staged_file.setAutoRemove(true); + if (!staged_file.open()) { + return Fail(error_message, + QStringLiteral("Could not create a temporary W3D export beside %1: %2") + .arg(QDir::toNativeSeparators(absolute_target_path), + staged_file.errorString())); + } + + staged_path = staged_file.fileName(); + staged_file.setAutoRemove(false); + staged_file.close(); + } + const TemporaryFileCleanup staged_file_cleanup(staged_path); + + const QByteArray native_staged_path = NativeFileName(staged_path); + RawFileClass raw_file(native_staged_path.constData()); + if (!raw_file.Open(FileClass::WRITE)) { + return Fail(error_message, + QStringLiteral("Could not open the temporary W3D export for writing: %1") + .arg(QDir::toNativeSeparators(staged_path))); + } + + bool writer_succeeded = false; + bool writer_threw = false; + bool chunk_write_error = false; + QString writer_exception; + int chunk_depth = 0; + { + ChunkSaveClass chunk_save(&raw_file); + try { + writer_succeeded = writer(chunk_save); + } catch (const std::exception &exception) { + writer_threw = true; + writer_exception = QString::fromLocal8Bit(exception.what()); + } catch (...) { + writer_threw = true; + } + chunk_depth = chunk_save.Cur_Chunk_Depth(); + chunk_write_error = chunk_save.Has_Write_Error(); + } + raw_file.Close(); + + if (writer_threw) { + const QString detail = writer_exception.isEmpty() + ? QStringLiteral("unknown exception") + : writer_exception; + return Fail(error_message, + QStringLiteral("The W3D export writer raised an exception: %1").arg(detail)); + } + if (chunk_write_error) { + return Fail(error_message, + QStringLiteral("The W3D export writer encountered a failed write or invalid chunk operation.")); + } + if (!writer_succeeded) { + return Fail(error_message, QStringLiteral("The W3D export writer reported a failure.")); + } + if (chunk_depth != 0) { + return Fail(error_message, + QStringLiteral("The W3D export writer left %1 chunk(s) unbalanced.") + .arg(chunk_depth)); + } + + if (!ValidateStagedFile(staged_path, expected_top_level_chunk, error_message)) { + return false; + } + + QFile staged_input(staged_path); + if (!staged_input.open(QIODevice::ReadOnly)) { + return Fail(error_message, + QStringLiteral("Could not read the validated temporary W3D export: %1") + .arg(staged_input.errorString())); + } + + QSaveFile destination(absolute_target_path); + destination.setDirectWriteFallback(false); + if (!destination.open(QIODevice::WriteOnly)) { + return Fail(error_message, + QStringLiteral("Could not prepare %1 for atomic replacement: %2") + .arg(QDir::toNativeSeparators(absolute_target_path), + destination.errorString())); + } + + constexpr qint64 copy_block_size = 64 * 1024; + while (true) { + const QByteArray block = staged_input.read(copy_block_size); + if (block.isEmpty()) { + if (staged_input.error() != QFileDevice::NoError) { + const QString detail = staged_input.errorString(); + destination.cancelWriting(); + return Fail(error_message, + QStringLiteral("Could not read the temporary W3D export: %1") + .arg(detail)); + } + break; + } + + qint64 offset = 0; + while (offset < block.size()) { + const qint64 written = + destination.write(block.constData() + offset, block.size() - offset); + if (written <= 0) { + const QString detail = destination.errorString(); + destination.cancelWriting(); + return Fail(error_message, + QStringLiteral("Could not write the atomic W3D replacement: %1") + .arg(detail)); + } + offset += written; + } + } + + if (!destination.commit()) { + return Fail(error_message, + QStringLiteral("Could not atomically replace %1: %2") + .arg(QDir::toNativeSeparators(absolute_target_path), + destination.errorString())); + } + + return true; +} +} diff --git a/Code/Tools/W3DViewQt/W3DExportUtils.h b/Code/Tools/W3DViewQt/W3DExportUtils.h new file mode 100644 index 000000000..18b933b35 --- /dev/null +++ b/Code/Tools/W3DViewQt/W3DExportUtils.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +#include +#include + +class ChunkSaveClass; + +namespace W3DExportUtils +{ +using ChunkWriter = std::function; + +bool SaveChunkFileAtomically(const QString &target_path, + std::uint32_t expected_top_level_chunk, + const ChunkWriter &writer, + QString *error_message = nullptr); +} diff --git a/Code/Tools/W3DViewQt/W3DViewQt.qrc b/Code/Tools/W3DViewQt/W3DViewQt.qrc new file mode 100644 index 000000000..fc028ebf5 --- /dev/null +++ b/Code/Tools/W3DViewQt/W3DViewQt.qrc @@ -0,0 +1,27 @@ + + + + ../W3DView/Res/W3DView.ico + ../W3DView/Res/Toolbar.bmp + ../W3DView/Res/icon2.ico + ../W3DView/Res/sound.ico + ../W3DView/Res/play.bmp + ../W3DView/Res/pause.bmp + ../W3DView/Res/stop.bmp + ../W3DView/Res/reverse.bmp + ../W3DView/Res/ffwd.bmp + ../W3DView/Res/playsel.bmp + ../W3DView/Res/pausesel.bmp + ../W3DView/Res/stopsel.bmp + ../W3DView/Res/reversesel.bmp + ../W3DView/Res/ffwdsel.bmp + ../W3DView/Res/xdir.bmp + ../W3DView/Res/xdirsel.bmp + ../W3DView/Res/ydir.bmp + ../W3DView/Res/ydirsel.bmp + ../W3DView/Res/zdir.bmp + ../W3DView/Res/zdirsel.bmp + ../W3DView/Res/rotatez.bmp + ../W3DView/Res/rotatezsel.bmp + + diff --git a/Code/Tools/W3DViewQt/W3DViewQt.rc b/Code/Tools/W3DViewQt/W3DViewQt.rc new file mode 100644 index 000000000..6da0eca3b --- /dev/null +++ b/Code/Tools/W3DViewQt/W3DViewQt.rc @@ -0,0 +1,2 @@ +1 ICON DISCARDABLE "../W3DView/Res/W3DView.ico" +LIGHT.W3D FILE DISCARDABLE "../W3DView/Res/Light.w3d" diff --git a/Code/Tools/W3DViewQt/W3DViewport.cpp b/Code/Tools/W3DViewQt/W3DViewport.cpp new file mode 100644 index 000000000..de1fbd2da --- /dev/null +++ b/Code/Tools/W3DViewQt/W3DViewport.cpp @@ -0,0 +1,2584 @@ +#include "W3DViewport.h" + +#include "RenderObjUtils.h" + +#include "agg_def.h" +#include "assetmgr.h" +#include "bmp2d.h" +#include "camera.h" +#include "dazzle.h" +#include "distlod.h" +#include "hanim.h" +#include "hlod.h" +#include "light.h" +#include "matrix3d.h" +#include "part_ldr.h" +#include "rcfile.h" +#include "refcount.h" +#include "rendobj.h" +#include "mesh.h" +#include "meshmdl.h" +#include "part_emt.h" +#include "ringobj.h" +#include "scene.h" +#include "SoundScene.h" +#include "soundrobj.h" +#include "sphereobj.h" +#include "vector2.h" +#include "vector3.h" +#include "WWAudio.h" +#include "ww3d.h" +#include "wwmath.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#endif + +namespace { +constexpr double kPi = 3.14159265358979323846; +constexpr double kRadToDeg = 180.0 / kPi; +constexpr double kDegToRad = kPi / 180.0; +constexpr const char *kCameraBoneName = "CAMERA"; + +Matrix3D BuildCameraBoneTransform(const Matrix3D &bone_transform) +{ + Matrix3D cam_transform(Vector3(0.0f, -1.0f, 0.0f), + Vector3(0.0f, 0.0f, 1.0f), + Vector3(-1.0f, 0.0f, 0.0f), + Vector3(0.0f, 0.0f, 0.0f)); + return bone_transform * cam_transform; +} + +bool GetCameraTransform(RenderObjClass *render_obj, Matrix3D &tm) +{ + if (!render_obj) { + return false; + } + + const int count = render_obj->Get_Num_Sub_Objects(); + for (int index = 0; index < count; ++index) { + RenderObjClass *sub_obj = render_obj->Get_Sub_Object(index); + if (!sub_obj) { + continue; + } + + const bool found = GetCameraTransform(sub_obj, tm); + sub_obj->Release_Ref(); + if (found) { + return true; + } + } + + const int bone_index = render_obj->Get_Bone_Index(kCameraBoneName); + if (bone_index > 0) { + tm = render_obj->Get_Bone_Transform(bone_index); + return true; + } + + return false; +} + +bool Is2DPreviewObject(const RenderObjClass *render_obj) +{ + return render_obj && render_obj->Class_ID() == RenderObjClass::CLASSID_BITMAP2D; +} + +SphereClass BuildEmitterDisplaySphere(const ParticleEmitterClass &emitter) +{ + const Vector3 velocity = emitter.Get_Start_Velocity(); + const Vector3 acceleration = emitter.Get_Acceleration(); + const float lifetime = emitter.Get_Lifetime(); + + Vector3 distance = + (velocity * lifetime) + ((acceleration * (lifetime * lifetime)) / 2.0f); + Vector3 distance_maxima(0.0f, 0.0f, 0.0f); + + if (acceleration.X != 0.0f || acceleration.Y != 0.0f || acceleration.Z != 0.0f) { + const Vector3 time_max( + acceleration.X != 0.0f ? -velocity.X / acceleration.X : 0.0f, + acceleration.Y != 0.0f ? -velocity.Y / acceleration.Y : 0.0f, + acceleration.Z != 0.0f ? -velocity.Z / acceleration.Z : 0.0f); + + if (time_max.X >= 0.0f && time_max.X < lifetime) { + distance_maxima.X = std::fabs( + (velocity.X * time_max.X) + + ((acceleration.X * time_max.X * time_max.X) / 2.0f)); + } + if (time_max.Y >= 0.0f && time_max.Y < lifetime) { + distance_maxima.Y = std::fabs( + (velocity.Y * time_max.Y) + + ((acceleration.Y * time_max.Y * time_max.Y) / 2.0f)); + } + if (time_max.Z >= 0.0f && time_max.Z < lifetime) { + distance_maxima.Z = std::fabs( + (velocity.Z * time_max.Z) + + ((acceleration.Z * time_max.Z * time_max.Z) / 2.0f)); + } + } + + distance.X = std::fabs(distance.X); + distance.Y = std::fabs(distance.Y); + distance.Z = std::fabs(distance.Z); + + float max_distance = std::max(distance.X, distance.Y); + max_distance = std::max(max_distance, distance.Z); + max_distance = std::max(max_distance, distance_maxima.X); + max_distance = std::max(max_distance, distance_maxima.Y); + max_distance = std::max(max_distance, distance_maxima.Z); + + Vector3 center = distance / 2.0f; + center.X = std::max(center.X, distance_maxima.X / 2.0f); + center.Y = std::max(center.Y, distance_maxima.Y / 2.0f); + center.Z = std::max(center.Z, distance_maxima.Z / 2.0f); + + SphereClass sphere; + sphere.Center = center; + sphere.Radius = + std::max(emitter.Get_Particle_Size() * 5.0f, (max_distance * 3.0f) / 5.0f); + return sphere; +} + +void StopAndDetachEmitterBuffers(RenderObjClass *render_obj) +{ + if (!render_obj) { + return; + } + + const int sub_object_count = render_obj->Get_Num_Sub_Objects(); + for (int index = 0; index < sub_object_count; ++index) { + RenderObjClass *sub_object = render_obj->Get_Sub_Object(index); + if (sub_object) { + StopAndDetachEmitterBuffers(sub_object); + sub_object->Release_Ref(); + } + } + + if (render_obj->Class_ID() == RenderObjClass::CLASSID_PARTICLEEMITTER) { + auto *emitter = static_cast(render_obj); + emitter->Stop(); + emitter->Remove_Buffer_From_Scene(); + emitter->Buffer_Scene_Not_Needed(); + } +} + +void ToggleAlternateMaterials(RenderObjClass *render_obj) +{ + if (!render_obj) { + return; + } + + if (render_obj->Class_ID() == RenderObjClass::CLASSID_MESH) { + auto *mesh = static_cast(render_obj); + MeshModelClass *model = mesh->Get_Model(); + if (model) { + model->Enable_Alternate_Material_Description( + !model->Is_Alternate_Material_Description_Enabled()); + } + } + + const int count = render_obj->Get_Num_Sub_Objects(); + for (int index = 0; index < count; ++index) { + RenderObjClass *sub_obj = render_obj->Get_Sub_Object(index); + if (sub_obj) { + ToggleAlternateMaterials(sub_obj); + sub_obj->Release_Ref(); + } + } +} + +class W3DViewScene final : public SimpleSceneClass +{ +public: + void SetAllowLodSwitching(bool enabled) { _allowLodSwitching = enabled; } + bool IsAllowLodSwitching() const { return _allowLodSwitching; } + + void Visibility_Check(CameraClass *camera) override + { + RefRenderObjListIterator it(&RenderList); + for (it.First(); !it.Is_Done(); it.Next()) { + RenderObjClass *robj = it.Peek_Obj(); + if (!robj) { + continue; + } + + if (robj->Is_Force_Visible()) { + robj->Set_Visible(true); + } else { + robj->Set_Visible(!camera->Cull_Sphere(robj->Get_Bounding_Sphere())); + } + + const int lod_level = robj->Get_LOD_Level(); + if (robj->Is_Really_Visible()) { + robj->Prepare_LOD(*camera); + } + + if (!_allowLodSwitching) { + robj->Set_LOD_Level(lod_level); + } + } + + Visibility_Checked = true; + } + + void Add_Render_Object(RenderObjClass *obj) override + { + SimpleSceneClass::Add_Render_Object(obj); + Recalculate_Fog_Planes(); + } + + void Add_To_Lineup(RenderObjClass *obj) + { + if (!obj || !Can_Line_Up(obj)) { + return; + } + + AABoxClass obj_box; + obj->Get_Obj_Space_Bounding_Box(obj_box); + const float obj_width = obj_box.Extent.Y * 2.0f; + + const AABoxClass scene_box = Get_Line_Up_Bounding_Box(); + const float scene_width = scene_box.Extent.Y * 2.0f; + + const float new_scene_width = scene_width + obj_width + obj_width / 3.0f; + const float delta = (new_scene_width - scene_width) / 2.0f; + + int existing_objects = 0; + SceneIterator *it = Create_Iterator(); + if (it) { + for (it->First(); !it->Is_Done(); it->Next()) { + RenderObjClass *current = it->Current_Item(); + if (!current || !Can_Line_Up(current)) { + continue; + } + Vector3 pos = current->Get_Position(); + pos.Y -= delta; + current->Set_Position(pos); + ++existing_objects; + } + Destroy_Iterator(it); + } + + if (existing_objects > 0) { + obj->Set_Position(Vector3(0.0f, new_scene_width / 2.0f - obj_box.Extent.Y, 0.0f)); + } else { + obj->Set_Position(Vector3(0.0f, 0.0f, 0.0f)); + } + + Add_Render_Object(obj); + _lineupList.Add(obj); + } + + void Clear_Lineup() + { + RenderObjClass *obj = nullptr; + while ((obj = _lineupList.Remove_Head()) != nullptr) { + StopAndDetachEmitterBuffers(obj); + Remove_Render_Object(obj); + obj->Release_Ref(); + } + Recalculate_Fog_Planes(); + } + + bool Can_Line_Up(RenderObjClass *obj) const + { + return obj && Can_Line_Up(obj->Class_ID()); + } + + bool Can_Line_Up(int class_id) const + { + return class_id == RenderObjClass::CLASSID_HMODEL || + class_id == RenderObjClass::CLASSID_HLOD; + } + + AABoxClass Get_Line_Up_Bounding_Box() + { + AABoxClass sum_of_boxes(Vector3(0.0f, 0.0f, 0.0f), Vector3(0.0f, 0.0f, 0.0f)); + SceneIterator *it = Create_Iterator(); + if (it) { + for (it->First(); !it->Is_Done(); it->Next()) { + RenderObjClass *current = it->Current_Item(); + if (current && Can_Line_Up(current)) { + sum_of_boxes.Add_Box(current->Get_Bounding_Box()); + } + } + Destroy_Iterator(it); + } + return sum_of_boxes; + } + + SphereClass Get_Bounding_Sphere() + { + SphereClass bounding_sphere(Vector3(0.0f, 0.0f, 0.0f), 0.0f); + SceneIterator *it = Create_Iterator(); + if (it) { + for (it->First(); !it->Is_Done(); it->Next()) { + RenderObjClass *current = it->Current_Item(); + if (!current) { + continue; + } + if (current->Class_ID() != RenderObjClass::CLASSID_LIGHT) { + bounding_sphere.Add_Sphere(current->Get_Bounding_Sphere()); + } + } + Destroy_Iterator(it); + } + return bounding_sphere; + } + + void Recalculate_Fog_Planes() + { + const float kFogOpaqueMultiple = 8.0f; + const float kFogMinimumDepth = 200.0f; + float fog_near = 0.0f; + float fog_far = 0.0f; + Get_Fog_Range(&fog_near, &fog_far); + + const SphereClass sphere = Get_Bounding_Sphere(); + fog_far = sphere.Radius * kFogOpaqueMultiple; + if (fog_far < fog_near + kFogMinimumDepth) { + fog_far = fog_near + kFogMinimumDepth; + } + Set_Fog_Range(fog_near, fog_far); + } + +private: + bool _allowLodSwitching = false; + RefRenderObjListClass _lineupList; +}; + +void SwitchLod(RenderObjClass *render_obj, int increment, bool &switched) +{ + if (!render_obj) { + return; + } + + const int count = render_obj->Get_Num_Sub_Objects(); + for (int index = 0; index < count; ++index) { + RenderObjClass *sub_obj = render_obj->Get_Sub_Object(index); + if (sub_obj) { + SwitchLod(sub_obj, increment, switched); + sub_obj->Release_Ref(); + } + } + + if (render_obj->Class_ID() == RenderObjClass::CLASSID_HLOD) { + auto *hlod = static_cast(render_obj); + hlod->Set_LOD_Level(hlod->Get_LOD_Level() + increment); + switched = true; + } +} +} // namespace + +static void SetLowestLod(RenderObjClass *render_obj); +static void ResetSceneLod(SceneClass *scene); + +W3DViewport::W3DViewport(QWidget *parent) + : QWidget(parent) +{ + setAttribute(Qt::WA_NativeWindow); + setAttribute(Qt::WA_PaintOnScreen); + setAttribute(Qt::WA_NoSystemBackground); + setAttribute(Qt::WA_OpaquePaintEvent); + setAutoFillBackground(false); + setFocusPolicy(Qt::StrongFocus); + + _timer.setInterval(16); + connect(&_timer, &QTimer::timeout, this, &W3DViewport::renderFrame); +} + +W3DViewport::~W3DViewport() +{ + clearAnimation(); + shutdownWW3D(); +} + +QPaintEngine *W3DViewport::paintEngine() const +{ + return nullptr; +} + +void W3DViewport::showEvent(QShowEvent *event) +{ + QWidget::showEvent(event); + initWW3D(); + + if (_initialized && !_timer.isActive()) { + _elapsed.restart(); + _timer.start(); + } +} + +void W3DViewport::resizeEvent(QResizeEvent *event) +{ + QWidget::resizeEvent(event); + if (_initialized && _windowed) { + const int render_width = _fullscreen && _initialDisplayWidth > 0 + ? _initialDisplayWidth + : width(); + const int render_height = _fullscreen && _initialDisplayHeight > 0 + ? _initialDisplayHeight + : height(); + int actual_width = 0; + int actual_height = 0; + int actual_bits_per_pixel = 0; + if (trySetDeviceResolution(render_width, + render_height, + _bitsPerPixel, + actual_width, + actual_height, + actual_bits_per_pixel)) { + _bitsPerPixel = actual_bits_per_pixel; + if (!_fullscreen) { + _initialDisplayWidth = actual_width; + _initialDisplayHeight = actual_height; + } + updateCameraFov(actual_width, actual_height); + } + } +} + +void W3DViewport::hideEvent(QHideEvent *event) +{ + QWidget::hideEvent(event); + if (_timer.isActive()) { + _timer.stop(); + } +} + +void W3DViewport::paintEvent(QPaintEvent *event) +{ + Q_UNUSED(event); +} + +void W3DViewport::focusOutEvent(QFocusEvent *event) +{ + setLightMeshVisible(false); + if (!_leftDown && !_rightDown) { + unsetCursor(); + } + QWidget::focusOutEvent(event); +} + +void W3DViewport::keyPressEvent(QKeyEvent *event) +{ + if (event && event->key() == Qt::Key_Control) { + setLightMeshVisible(true); + event->accept(); + return; + } + QWidget::keyPressEvent(event); +} + +void W3DViewport::keyReleaseEvent(QKeyEvent *event) +{ + if (event && event->key() == Qt::Key_Control) { + setLightMeshVisible(false); + event->accept(); + return; + } + QWidget::keyReleaseEvent(event); +} + +void W3DViewport::mousePressEvent(QMouseEvent *event) +{ + if (event->button() == Qt::LeftButton) { + _leftDown = true; + } else if (event->button() == Qt::RightButton) { + _rightDown = true; + } + + _lastPos = event->pos(); + updateInteractionCursor(); + grabMouse(); + event->accept(); +} + +void W3DViewport::mouseReleaseEvent(QMouseEvent *event) +{ + if (event->button() == Qt::LeftButton) { + _leftDown = false; + } else if (event->button() == Qt::RightButton) { + _rightDown = false; + } + + if (!_leftDown && !_rightDown) { + releaseMouse(); + } + updateInteractionCursor(); + + event->accept(); +} + +void W3DViewport::mouseMoveEvent(QMouseEvent *event) +{ + if (!_initialized || !_camera) { + _lastPos = event->pos(); + return; + } + + const QPoint current = event->pos(); + const int delta_x = _lastPos.x() - current.x(); + const int delta_y = _lastPos.y() - current.y(); + + const bool control_down = event->modifiers().testFlag(Qt::ControlModifier); + + setLightMeshVisible(control_down); + + if (_leftDown && _rightDown) { + const float mid_x = width() * 0.5f; + const float mid_y = height() * 0.5f; + if (mid_x > 0.0f && mid_y > 0.0f) { + const float last_x = (static_cast(_lastPos.x()) - mid_x) / mid_x; + const float last_y = (mid_y - static_cast(_lastPos.y())) / mid_y; + const float point_x = (static_cast(current.x()) - mid_x) / mid_x; + const float point_y = (mid_y - static_cast(current.y())) / mid_y; + + const Vector3 camera_pan(-1.0f * _cameraDistance * (point_x - last_x), + -1.0f * _cameraDistance * (point_y - last_y), + 0.0f); + + Matrix3D transform = _camera->Get_Transform(); + transform.Translate(camera_pan); + + const Matrix3 view = Build_Matrix3(_rotation); + const Vector3 move = view * camera_pan; + _orbitCenter += move; + + _camera->Set_Transform(transform); + } + } else if (control_down && _leftDown && _sceneLight) { + const float mid_x = width() * 0.5f; + const float mid_y = height() * 0.5f; + if (mid_x > 0.0f && mid_y > 0.0f) { + const float last_x = (static_cast(_lastPos.x()) - mid_x) / mid_x; + const float last_y = (mid_y - static_cast(_lastPos.y())) / mid_y; + const float point_x = (static_cast(current.x()) - mid_x) / mid_x; + const float point_y = (mid_y - static_cast(current.y())) / mid_y; + + const Quaternion mouse_motion = Inverse(Trackball(last_x, last_y, point_x, point_y, 0.8f)); + const Quaternion camera_orientation = Build_Quaternion(_camera->Get_Transform()); + const Quaternion current_light = Build_Quaternion(_sceneLight->Get_Transform()); + + Quaternion light_orientation = camera_orientation * mouse_motion; + light_orientation = light_orientation * Inverse(camera_orientation); + light_orientation = light_orientation * current_light; + light_orientation.Normalize(); + + Vector3 center_in_light_space; + const Matrix3D current_transform = _sceneLight->Get_Transform(); + Matrix3D::Inverse_Transform_Vector( + current_transform, _orbitCenter, ¢er_in_light_space); + + Matrix3D light_transform(light_orientation, _orbitCenter); + light_transform.Translate(-center_in_light_space); + _sceneLight->Set_Transform(light_transform); + _sceneLightOrientation = light_orientation; + _sceneLightOrientationSet = true; + _sceneLightDistance = (_sceneLight->Get_Position() - _orbitCenter).Length(); + _sceneLightDistanceSet = true; + syncLightMesh(); + } + } else if (control_down && _rightDown && _sceneLight && _renderObject) { + if (height() > 0 && delta_y != 0) { + const float radius = std::max(0.0f, _renderObject->Get_Bounding_Sphere().Radius); + const float adjustment = (static_cast(delta_y) / static_cast(height())) + * radius * 3.0f; + + Matrix3D light_transform = _sceneLight->Get_Transform(); + light_transform.Translate(Vector3(0.0f, 0.0f, adjustment)); + const float new_distance = + (light_transform.Get_Translation() - _renderObject->Get_Position()).Length(); + if (new_distance > radius) { + _sceneLight->Set_Transform(light_transform); + _sceneLightDistance = (_sceneLight->Get_Position() - _orbitCenter).Length(); + _sceneLightDistanceSet = true; + syncLightMesh(); + } + } + } else if (_leftDown) { + const float mid_x = width() * 0.5f; + const float mid_y = height() * 0.5f; + if (mid_x > 0.0f && mid_y > 0.0f) { + const float last_x = (static_cast(_lastPos.x()) - mid_x) / mid_x; + const float last_y = (mid_y - static_cast(_lastPos.y())) / mid_y; + const float point_x = (static_cast(current.x()) - mid_x) / mid_x; + const float point_y = (mid_y - static_cast(current.y())) / mid_y; + + Quaternion rotation = Trackball(last_x, last_y, point_x, point_y, 0.8f); + + if (_allowedRotation == CameraRotation::OnlyX) { + Matrix3D temp_matrix = Build_Matrix3D(rotation); + Matrix3D temp_matrix2(1); + temp_matrix2.Rotate_X(temp_matrix.Get_X_Rotation()); + temp_matrix2.Set_Translation(temp_matrix.Get_Translation()); + rotation = Build_Quaternion(temp_matrix2); + } else if (_allowedRotation == CameraRotation::OnlyY) { + Matrix3D temp_matrix = Build_Matrix3D(rotation); + Matrix3D temp_matrix2(1); + temp_matrix2.Rotate_Y(temp_matrix.Get_Y_Rotation()); + temp_matrix2.Set_Translation(temp_matrix.Get_Translation()); + rotation = Build_Quaternion(temp_matrix2); + } else if (_allowedRotation == CameraRotation::OnlyZ) { + Matrix3D temp_matrix = Build_Matrix3D(rotation); + Matrix3D temp_matrix2(1); + temp_matrix2.Rotate_Z(temp_matrix.Get_Z_Rotation()); + temp_matrix2.Set_Translation(temp_matrix.Get_Translation()); + rotation = Build_Quaternion(temp_matrix2); + } + + _rotation = rotation; + + Matrix3D transform = _camera->Get_Transform(); + Matrix3D inverse; + transform.Get_Orthogonal_Inverse(inverse); + + const Vector3 to_object = inverse * _orbitCenter; + transform.Translate(to_object); + Matrix3D::Multiply(transform, Build_Matrix3D(rotation), &transform); + transform.Translate(-to_object); + + _camera->Set_Transform(transform); + } + } else if (_rightDown) { + if (height() > 0 && delta_y != 0) { + Matrix3D transform = _camera->Get_Transform(); + const float delta = static_cast(delta_y) / static_cast(height()); + float adjustment = delta * _cameraDistance * 3.0f; + + if ((adjustment < _minZoomAdjust) && (adjustment >= 0.0f)) { + adjustment = _minZoomAdjust; + } + if ((adjustment > -_minZoomAdjust) && (adjustment <= 0.0f)) { + adjustment = -_minZoomAdjust; + } + + if ((_cameraDistance + adjustment) > 0.0f) { + _cameraDistance += adjustment; + transform.Translate(Vector3(0.0f, 0.0f, adjustment)); + _camera->Set_Transform(transform); + } + } + } + + _lastPos = current; + event->accept(); +} + +void W3DViewport::wheelEvent(QWheelEvent *event) +{ + if (!_initialized || !_camera) { + return; + } + + const int delta = event->angleDelta().y(); + if (delta == 0) { + return; + } + + Matrix3D transform = _camera->Get_Transform(); + const float steps = static_cast(delta) / 120.0f; + float adjustment = -steps * _cameraDistance * 0.15f; + + if ((adjustment < _minZoomAdjust) && (adjustment >= 0.0f)) { + adjustment = _minZoomAdjust; + } + if ((adjustment > -_minZoomAdjust) && (adjustment <= 0.0f)) { + adjustment = -_minZoomAdjust; + } + + if ((_cameraDistance + adjustment) > 0.0f) { + _cameraDistance += adjustment; + transform.Translate(Vector3(0.0f, 0.0f, adjustment)); + _camera->Set_Transform(transform); + } + + event->accept(); +} + +void W3DViewport::renderFrame() +{ + if (!_initialized) { + return; + } + + const qint64 elapsed_ms = _elapsed.restart(); + updateFrameTiming(static_cast(elapsed_ms)); + if (elapsed_ms > 0) { + const auto next_time = WW3D::Get_Sync_Time() + static_cast(elapsed_ms); + WW3D::Sync(next_time); + updateAnimation(static_cast(elapsed_ms) / 1000.0f); + } + + updateCameraAnimation(); + updateObjectRotation(); + updateLightRotation(); + renderScene(); +} + +void W3DViewport::renderScene(bool present) +{ + if (_allowLodSwitching && _scene) { + ResetSceneLod(_scene); + } + + WW3D::Begin_Render(true, true, _clearColor); + if (_backgroundScene && _backgroundCamera) { + WW3D::Render(_backgroundScene, _backgroundCamera, false, false); + } + if (_preview2DScene && _preview2DCamera) { + WW3D::Render(_preview2DScene, _preview2DCamera, false, false); + } + if (_backgroundObjectScene && _backgroundObjectCamera) { + updateBackgroundObjectCamera(); + WW3D::Render(_backgroundObjectScene, _backgroundObjectCamera, false, false); + } + if (_scene && _camera) { + WW3D::Render(_scene, _camera, false, false); + if (_dazzleLayer) { + _dazzleLayer->Render(_camera); + } + } + WW3D::End_Render(present); + + if (auto *audio = WWAudioClass::Get_Instance()) { + audio->On_Frame_Update(); + } +} + +int W3DViewport::captureScreenshot(const QString &basePath) +{ + if (!_initialized || basePath.isEmpty()) { + return 0; + } + + // A discard swap chain does not preserve the back buffer after Present. + // Render the current scene without presenting so the screenshot contains + // the exact viewport frame. The active frame timer will render and present + // the next display frame; only render synchronously when that timer is off. + renderScene(false); + const QByteArray native = QDir::toNativeSeparators(basePath).toLocal8Bit(); + const int screenshot_number = WW3D::Make_Back_Buffer_Screen_Shot(native.constData()); + if (!_timer.isActive()) { + renderScene(true); + } + return screenshot_number; +} + +void W3DViewport::renderFrameWithTicks(int ticks, bool present) +{ + if (!_initialized) { + return; + } + + if (ticks > 0) { + updateFrameTiming(static_cast(ticks)); + const auto next_time = WW3D::Get_Sync_Time() + static_cast(ticks); + WW3D::Sync(next_time); + updateAnimation(static_cast(ticks) / 1000.0f); + } + + updateCameraAnimation(); + updateObjectRotation(); + updateLightRotation(); + renderScene(present); +} + +void W3DViewport::updateFrameTiming(float elapsedMs) +{ + if (elapsedMs <= 0.0f) { + return; + } + + _frameTimeAccumMs += elapsedMs; + ++_frameTimeSamples; + + if (_frameTimeAccumMs >= 1000.0f) { + _averageFrameMs = _frameTimeAccumMs / static_cast(_frameTimeSamples); + _frameTimeAccumMs = 0.0f; + _frameTimeSamples = 0; + } +} + +static void SetLowestLod(RenderObjClass *render_obj) +{ + if (!render_obj) { + return; + } + + const int count = render_obj->Get_Num_Sub_Objects(); + for (int index = 0; index < count; ++index) { + RenderObjClass *sub_obj = render_obj->Get_Sub_Object(index); + if (sub_obj) { + SetLowestLod(sub_obj); + sub_obj->Release_Ref(); + } + } + + if (render_obj->Class_ID() == RenderObjClass::CLASSID_HLOD) { + static_cast(render_obj)->Set_LOD_Level(0); + } +} + +static void ResetSceneLod(SceneClass *scene) +{ + if (!scene) { + return; + } + + SceneIterator *it = scene->Create_Iterator(); + if (!it) { + return; + } + + for (it->First(); !it->Is_Done(); it->Next()) { + RenderObjClass *obj = it->Current_Item(); + SetLowestLod(obj); + } + + scene->Destroy_Iterator(it); +} + +void W3DViewport::initWW3D() +{ + if (_initialized) { + return; + } + + createWinId(); + void *hwnd = reinterpret_cast(winId()); + + if (WW3D::Init(hwnd, nullptr, false) != WW3D_ERROR_OK) { + return; + } + + WW3D::Enable_Static_Sort_Lists(true); + + const int render_width = _fullscreen && _initialDisplayWidth > 0 + ? _initialDisplayWidth + : width(); + const int render_height = _fullscreen && _initialDisplayHeight > 0 + ? _initialDisplayHeight + : height(); + if (WW3D::Set_Render_Device(-1, + render_width, + render_height, + _bitsPerPixel, + 1, + true) != WW3D_ERROR_OK) { + WW3D::Shutdown(); + return; + } + + int actual_width = render_width; + int actual_height = render_height; + int actual_bits_per_pixel = _bitsPerPixel; + bool actual_windowed = _windowed; + WW3D::Get_Device_Resolution( + actual_width, actual_height, actual_bits_per_pixel, actual_windowed); + _windowed = actual_windowed; + _bitsPerPixel = actual_bits_per_pixel; + _initialDisplayWidth = actual_width; + _initialDisplayHeight = actual_height; + + if (auto *asset_manager = WW3DAssetManager::Get_Instance()) { + asset_manager->Load_Procedural_Textures(); + + ResourceFileClass light_mesh_file("Light.w3d"); + if (light_mesh_file.Is_Available(0) + && asset_manager->Load_3D_Assets(light_mesh_file)) { + _lightMesh = asset_manager->Create_Render_Obj("LIGHT"); + } + } + + if (DazzleRenderObjClass::Get_Type_Class(0)) { + _dazzleLayer = new DazzleLayerClass(); + DazzleRenderObjClass::Set_Current_Dazzle_Layer(_dazzleLayer); + DazzleRenderObjClass::Enable_Dazzle_Rendering(true); + } else { + DazzleRenderObjClass::Set_Current_Dazzle_Layer(nullptr); + DazzleRenderObjClass::Enable_Dazzle_Rendering(false); + } + + initScene(); + updateCameraFov(actual_width, actual_height); + _initialized = true; +} + +void W3DViewport::shutdownWW3D() +{ + if (!_initialized) { + // A hidden/offscreen viewport can still receive a render object through + // tree selection even though showEvent() never initialized WW3D. Release + // those scene-independent references before the asset manager shuts down. + shutdownScene(); + REF_PTR_RELEASE(_lightMesh); + return; + } + + _timer.stop(); + shutdownScene(); + REF_PTR_RELEASE(_lightMesh); + if (_dazzleLayer) { + DazzleRenderObjClass::Set_Current_Dazzle_Layer(nullptr); + delete _dazzleLayer; + _dazzleLayer = nullptr; + } + DazzleRenderObjClass::Enable_Dazzle_Rendering(false); + WW3D::Shutdown(); + _initialized = false; +} + +void W3DViewport::initScene() +{ + if (_scene || _camera) { + return; + } + + ParticleEmitterClass::Set_Default_Remove_On_Complete(false); + + _scene = NEW_REF(W3DViewScene, ()); + setWireframeEnabled(_wireframeEnabled); + _scene->Set_Ambient_Light(_ambientLight); + _scene->Set_Fog_Color(_clearColor); + _scene->Set_Fog_Enable(_fogEnabled); + setLodAutoSwitchingEnabled(_allowLodSwitching); + + _backgroundScene = NEW_REF(SimpleSceneClass, ()); + _backgroundCamera = NEW_REF(CameraClass, ()); + _backgroundCamera->Set_View_Plane(Vector2(-1.0f, -1.0f), Vector2(1.0f, 1.0f)); + _backgroundCamera->Set_Position(Vector3(0.0f, 0.0f, 1.0f)); + _backgroundCamera->Set_Clip_Planes(0.1f, 10.0f); + refreshBackgroundBitmap(); + + _preview2DScene = NEW_REF(SimpleSceneClass, ()); + _preview2DCamera = NEW_REF(CameraClass, ()); + _preview2DCamera->Set_View_Plane(Vector2(-1.0f, -1.0f), Vector2(1.0f, 1.0f)); + _preview2DCamera->Set_Position(Vector3(0.0f, 0.0f, 1.0f)); + _preview2DCamera->Set_Clip_Planes(0.1f, 10.0f); + + _backgroundObjectScene = NEW_REF(SimpleSceneClass, ()); + _backgroundObjectScene->Set_Ambient_Light(Vector3(0.5f, 0.5f, 0.5f)); + _backgroundObjectCamera = NEW_REF(CameraClass, ()); + _backgroundObjectCamera->Set_View_Plane(Vector2(-1.0f, -1.0f), Vector2(1.0f, 1.0f)); + _backgroundObjectCamera->Set_Position(Vector3(0.0f, 0.0f, 0.0f)); + _backgroundObjectCamera->Set_Clip_Planes(0.1f, 10.0f); + if (!_backgroundObjectName.isEmpty()) { + setBackgroundObjectName(_backgroundObjectName); + } + + _sceneLight = NEW_REF(LightClass, ()); + _sceneLight->Set_Position(Vector3(0.0f, 5000.0f, 3000.0f)); + _sceneLight->Set_Intensity(1.0f); + _sceneLight->Set_Force_Visible(true); + _sceneLight->Set_Flag(LightClass::NEAR_ATTENUATION, false); + _sceneLight->Set_Far_Attenuation_Range(1000000.0f, 1000000.0f); + _sceneLight->Set_Ambient(Vector3(0.0f, 0.0f, 0.0f)); + _sceneLight->Set_Diffuse(Vector3(1.0f, 1.0f, 1.0f)); + _sceneLight->Set_Specular(Vector3(1.0f, 1.0f, 1.0f)); + _scene->Add_Render_Object(_sceneLight); + + _camera = NEW_REF(CameraClass, ()); + Matrix3D transform(1); + transform.Look_At(Vector3(35.0f, 0.0f, 0.0f), Vector3(0.0f, 0.0f, 0.0f), 0); + _camera->Set_Transform(transform); + _camera->Set_Clip_Planes(0.2f, 10000.0f); + updateCameraFov(width(), height()); + if (_manualClipPlanes) { + setCameraClipPlanes(_manualNear, _manualFar); + } + + if (auto *audio = WWAudioClass::Get_Instance()) { + if (auto *sound_scene = audio->Get_Sound_Scene()) { + sound_scene->Attach_Listener_To_Obj(_camera); + } + } + + if (_renderObject) { + addRenderObjectToDisplayScene(_renderObject); + if (!Is2DPreviewObject(_renderObject)) { + resetCameraToObject(*_renderObject); + } + } + + applySceneLightSettings(); + syncLightMesh(); +} + +void W3DViewport::shutdownScene() +{ + if (auto *audio = WWAudioClass::Get_Instance()) { + if (auto *sound_scene = audio->Get_Sound_Scene()) { + sound_scene->Attach_Listener_To_Obj(nullptr); + } + } + + if (_scene) { + auto *viewer_scene = static_cast(_scene); + viewer_scene->Clear_Lineup(); + } + if (_renderObject) { + removeRenderObjectFromDisplayScene(_renderObject); + } + if (_scene && _sceneLight) { + _scene->Remove_Render_Object(_sceneLight); + } + if (_scene && _lightMesh && _lightMeshInScene) { + _scene->Remove_Render_Object(_lightMesh); + _lightMeshInScene = false; + } + if (_backgroundBitmapObj) { + _backgroundBitmapObj->Remove(); + } + REF_PTR_RELEASE(_backgroundBitmapObj); + if (_backgroundObject) { + _backgroundObject->Remove(); + } + REF_PTR_RELEASE(_backgroundObject); + REF_PTR_RELEASE(_backgroundObjectCamera); + REF_PTR_RELEASE(_backgroundObjectScene); + REF_PTR_RELEASE(_preview2DCamera); + REF_PTR_RELEASE(_preview2DScene); + REF_PTR_RELEASE(_backgroundCamera); + REF_PTR_RELEASE(_backgroundScene); + REF_PTR_RELEASE(_animation); + REF_PTR_RELEASE(_renderObject); + REF_PTR_RELEASE(_sceneLight); + REF_PTR_RELEASE(_camera); + REF_PTR_RELEASE(_scene); +} + +void W3DViewport::addRenderObjectToDisplayScene(RenderObjClass *object) +{ + if (!object) { + return; + } + + if (Is2DPreviewObject(object)) { + if (_preview2DScene) { + _preview2DScene->Add_Render_Object(object); + } + return; + } + + if (!_scene) { + return; + } + + _scene->Add_Render_Object(object); + if (object->Class_ID() == RenderObjClass::CLASSID_PARTICLEEMITTER) { + auto *emitter = static_cast(object); + emitter->Enable_Remove_On_Complete(false); + emitter->Start(); + } +} + +void W3DViewport::removeRenderObjectFromDisplayScene(RenderObjClass *object) +{ + if (!object) { + return; + } + + if (Is2DPreviewObject(object)) { + if (_preview2DScene) { + _preview2DScene->Remove_Render_Object(object); + } + return; + } + + StopAndDetachEmitterBuffers(object); + + if (_scene) { + _scene->Remove_Render_Object(object); + } +} + +void W3DViewport::updateCameraFov(int width, int height, bool force) +{ + if (!_camera || width <= 0 || height <= 0) { + return; + } + + if (_manualFov && !force) { + if (_manualHfov > 0.0 && _manualVfov > 0.0) { + _camera->Set_View_Plane(_manualHfov, _manualVfov); + } + return; + } + + float hfov = DEG_TO_RADF(45.0f); + float vfov = DEG_TO_RADF(45.0f); + + if (height > width) { + vfov = DEG_TO_RADF(45.0f); + hfov = (static_cast(width) / static_cast(height)) * vfov; + } else { + hfov = DEG_TO_RADF(45.0f); + vfov = (static_cast(height) / static_cast(width)) * hfov; + } + + _camera->Set_View_Plane(hfov, vfov); +} + +void W3DViewport::resetCameraToObject(RenderObjClass &object) +{ + if (!_camera) { + return; + } + + const SphereClass sphere = object.Class_ID() == RenderObjClass::CLASSID_PARTICLEEMITTER + ? BuildEmitterDisplaySphere(static_cast(object)) + : object.Get_Bounding_Sphere(); + const Vector3 old_center = _orbitCenter; + _orbitCenter = sphere.Center; + _cameraDistance = std::max(1.0f, sphere.Radius * 3.0f); + _minZoomAdjust = _cameraDistance / 190.0f; + Matrix3D transform(1); + transform.Look_At(_orbitCenter + Vector3(_cameraDistance, 0.0f, 0.0f), _orbitCenter, 0); + _rotation = Build_Quaternion(transform); + _camera->Set_Transform(transform); + if (!_manualClipPlanes) { + const float min_clip = std::max(0.2f, _minZoomAdjust * 0.5f); + _camera->Set_Clip_Planes(min_clip, _cameraDistance * 60.0f); + setFogNearAndRecalculate(min_clip); + } + + const int bone_index = object.Get_Bone_Index(kCameraBoneName); + if (bone_index > 0) { + Matrix3D bone_transform = object.Get_Bone_Transform(bone_index); + if (_cameraBonePosX) { + bone_transform = BuildCameraBoneTransform(bone_transform); + } + _camera->Set_Transform(bone_transform); + } + + if (_sceneLight) { + if (!_sceneLightOrientationSet && !_sceneLightDistanceSet) { + Matrix3D light_tm(1); + light_tm.Set_Translation(_orbitCenter); + light_tm.Translate(Vector3(0.0f, 0.0f, 0.7f * _cameraDistance)); + _sceneLight->Set_Transform(light_tm); + _sceneLightDistance = 0.7f * _cameraDistance; + } else { + updateSceneLightPosition(old_center); + } + syncLightMesh(); + } + emit objectCameraReset(); +} + +void W3DViewport::setRenderObject(RenderObjClass *object) +{ + if (_renderObject) { + removeRenderObjectFromDisplayScene(_renderObject); + } + if (_scene) { + auto *viewer_scene = static_cast(_scene); + viewer_scene->Clear_Lineup(); + } + REF_PTR_RELEASE(_renderObject); + + if (!object) { + return; + } + + REF_PTR_SET(_renderObject, object); + _renderObject->Set_Transform(Matrix3D(1)); + addRenderObjectToDisplayScene(_renderObject); + if (_scene) { + if ((_autoResetCamera || _oneTimeCameraReset) && !Is2DPreviewObject(_renderObject)) { + resetCameraToObject(*_renderObject); + _oneTimeCameraReset = false; + } + } + + if (_renderObject) { + if (_animationCombo) { + _renderObject->Set_Animation(_animationCombo); + } else if (_animation) { + if (_animationBlend) { + _renderObject->Set_Animation(_animation, _animationFrame); + } else { + _renderObject->Set_Animation(_animation, static_cast(_animationFrame)); + } + } + } +} + +void W3DViewport::setManualFovEnabled(bool enabled) +{ + _manualFov = enabled; + if (!_camera) { + return; + } + + if (_manualFov) { + if (_manualHfov > 0.0 && _manualVfov > 0.0) { + _camera->Set_View_Plane(_manualHfov, _manualVfov); + } + } else { + updateCameraFov(width(), height(), true); + } +} + +bool W3DViewport::isManualFovEnabled() const +{ + return _manualFov; +} + +void W3DViewport::setManualClipPlanesEnabled(bool enabled) +{ + _manualClipPlanes = enabled; + if (_manualClipPlanes && _camera) { + setCameraClipPlanes(_manualNear, _manualFar); + } +} + +bool W3DViewport::isManualClipPlanesEnabled() const +{ + return _manualClipPlanes; +} + +void W3DViewport::setCameraFovDegrees(double hfov_deg, double vfov_deg) +{ + const double hfov = hfov_deg * kDegToRad; + const double vfov = vfov_deg * kDegToRad; + _manualHfov = hfov; + _manualVfov = vfov; + if (_camera) { + _camera->Set_View_Plane(hfov, vfov); + } +} + +void W3DViewport::cameraFovDegrees(double &hfov_deg, double &vfov_deg) const +{ + double hfov = _manualHfov; + double vfov = _manualVfov; + if (_camera) { + hfov = _camera->Get_Horizontal_FOV(); + vfov = _camera->Get_Vertical_FOV(); + } else if (hfov <= 0.0 || vfov <= 0.0) { + hfov = 45.0 * kDegToRad; + vfov = 45.0 * kDegToRad; + } + + hfov_deg = hfov * kRadToDeg; + vfov_deg = vfov * kRadToDeg; +} + +void W3DViewport::setCameraClipPlanes(float znear, float zfar) +{ + _manualNear = znear; + _manualFar = zfar; + if (_camera) { + _camera->Set_Clip_Planes(znear, zfar); + } + setFogNearAndRecalculate(znear); +} + +void W3DViewport::cameraClipPlanes(float &znear, float &zfar) const +{ + if (_camera) { + _camera->Get_Clip_Planes(znear, zfar); + return; + } + + znear = _manualNear; + zfar = _manualFar; +} + +void W3DViewport::setFogNearAndRecalculate(float nearClip) +{ + if (!_scene) { + return; + } + + float fogNear = 0.0f; + float fogFar = 0.0f; + _scene->Get_Fog_Range(&fogNear, &fogFar); + _scene->Set_Fog_Range(nearClip, fogFar); + static_cast(_scene)->Recalculate_Fog_Planes(); +} + +void W3DViewport::resetFov() +{ + updateCameraFov(width(), height(), true); +} + +void W3DViewport::setCameraDistance(float distance) +{ + if (!_camera) { + _cameraDistance = distance; + return; + } + + _cameraDistance = distance; + if (_cameraDistance < 0.0f) { + _cameraDistance = 0.0f; + } + + Matrix3D transform(1); + transform.Look_At(_orbitCenter + Vector3(_cameraDistance, 0.0f, 0.0f), _orbitCenter, 0.0f); + _camera->Set_Transform(transform); + _rotation = Build_Quaternion(transform); + _minZoomAdjust = _cameraDistance / 190.0f; +} + +float W3DViewport::cameraDistance() const +{ + return _cameraDistance; +} + +void W3DViewport::setInitialDisplayMode(int width, + int height, + int bitsPerPixel, + bool fullscreen) +{ + if (_initialized) { + return; + } + + _initialDisplayWidth = width > 0 ? width : 0; + _initialDisplayHeight = height > 0 ? height : 0; + _bitsPerPixel = bitsPerPixel > 0 ? bitsPerPixel : 32; + _fullscreen = fullscreen; + // WW3D's exclusive-fullscreen path assumes its render HWND is a + // top-level legacy window and rewrites that window's style. The Qt + // viewport is a native child widget, so Direct3D remains windowed while + // the QMainWindow owns the borderless fullscreen state. + _windowed = true; +} + +bool W3DViewport::applyResolution(int width, int height, int bitsPerPixel, bool fullscreen) +{ + if (!_initialized || width <= 0 || height <= 0) { + return false; + } + + const int bpp = bitsPerPixel > 0 ? bitsPerPixel : _bitsPerPixel; + const int render_width = fullscreen + ? width + : this->width(); + const int render_height = fullscreen + ? height + : this->height(); + int actual_width = 0; + int actual_height = 0; + int actual_bits_per_pixel = 0; + if (!trySetDeviceResolution(render_width, + render_height, + bpp, + actual_width, + actual_height, + actual_bits_per_pixel)) { + return false; + } + + _initialDisplayWidth = actual_width; + _initialDisplayHeight = actual_height; + _fullscreen = fullscreen; + _bitsPerPixel = actual_bits_per_pixel; + _windowed = true; + updateCameraFov(actual_width, actual_height, true); + if (_manualClipPlanes) { + setCameraClipPlanes(_manualNear, _manualFar); + } + + return true; +} + +bool W3DViewport::trySetDeviceResolution(int width, + int height, + int bitsPerPixel, + int &actualWidth, + int &actualHeight, + int &actualBitsPerPixel) +{ + if (!_initialized || width <= 0 || height <= 0 || bitsPerPixel <= 0) { + return false; + } + + int previous_width = 0; + int previous_height = 0; + int previous_bits_per_pixel = 0; + bool previous_windowed = true; + WW3D::Get_Device_Resolution( + previous_width, previous_height, previous_bits_per_pixel, previous_windowed); + + // The Qt renderer deliberately uses a windowed Direct3D child surface, + // whose bit depth is fixed to the desktop mode. + if (!previous_windowed || bitsPerPixel != previous_bits_per_pixel) { + return false; + } + + actualWidth = previous_width; + actualHeight = previous_height; + actualBitsPerPixel = previous_bits_per_pixel; + if (width == previous_width && height == previous_height) { + return true; + } + + if (WW3D::Set_Device_Resolution(width, height, bitsPerPixel, 1, false) != + WW3D_ERROR_OK) { + return false; + } + + bool actual_windowed = true; + WW3D::Get_Device_Resolution( + actualWidth, actualHeight, actualBitsPerPixel, actual_windowed); + if (actual_windowed && actualWidth == width && actualHeight == height && + actualBitsPerPixel == bitsPerPixel) { + return true; + } + + WW3D::Set_Device_Resolution(previous_width, + previous_height, + previous_bits_per_pixel, + previous_windowed ? 1 : 0, + false); + return false; +} + +bool W3DViewport::addToLineup(RenderObjClass *object) +{ + if (!_scene || !object) { + return false; + } + + auto *viewer_scene = static_cast(_scene); + if (!viewer_scene->Can_Line_Up(object)) { + return false; + } + + viewer_scene->Add_To_Lineup(object); + return true; +} + +bool W3DViewport::canLineUpClass(int class_id) const +{ + if (!_scene) { + return false; + } + + auto *viewer_scene = static_cast(_scene); + return viewer_scene->Can_Line_Up(class_id); +} + +void W3DViewport::clearLineup() +{ + if (!_scene) { + return; + } + + auto *viewer_scene = static_cast(_scene); + viewer_scene->Clear_Lineup(); +} + +void W3DViewport::setObjectRotationFlags(int flags) +{ + _objectRotation = flags; +} + +int W3DViewport::objectRotationFlags() const +{ + return _objectRotation; +} + +void W3DViewport::resetObjectTransform() +{ + if (!_renderObject) { + return; + } + + _renderObject->Set_Transform(Matrix3D(1)); +} + +void W3DViewport::toggleAlternateMaterials() +{ + ToggleAlternateMaterials(_renderObject); +} + +void W3DViewport::setLightRotationFlags(int flags) +{ + _lightRotation = flags; +} + +int W3DViewport::lightRotationFlags() const +{ + return _lightRotation; +} + +float W3DViewport::currentScreenSize() const +{ + if (!_renderObject || !_camera) { + return 0.0f; + } + + return _renderObject->Get_Screen_Size(*_camera); +} + +void W3DViewport::setCameraPosition(CameraPosition position) +{ + if (!_camera || !_renderObject) { + return; + } + + const SphereClass sphere = _renderObject->Get_Bounding_Sphere(); + const Vector3 old_center = _orbitCenter; + _orbitCenter = sphere.Center; + _cameraDistance = sphere.Radius * 3.0f; + if (_cameraDistance < 1.0f) { + _cameraDistance = 1.0f; + } + if (_cameraDistance > 400.0f) { + _cameraDistance = 400.0f; + } + + _minZoomAdjust = _cameraDistance / 190.0f; + + Matrix3D transform(1); + switch (position) { + case CameraPosition::Front: + transform.Look_At(_orbitCenter + Vector3(_cameraDistance, 0.0f, 0.0f), _orbitCenter, 0.0f); + break; + case CameraPosition::Back: + transform.Look_At(_orbitCenter + Vector3(-_cameraDistance, 0.0f, 0.0f), _orbitCenter, 0.0f); + break; + case CameraPosition::Left: + transform.Look_At(_orbitCenter + Vector3(0.0f, -_cameraDistance, 0.0f), _orbitCenter, 0.0f); + break; + case CameraPosition::Right: + transform.Look_At(_orbitCenter + Vector3(0.0f, _cameraDistance, 0.0f), _orbitCenter, 0.0f); + break; + case CameraPosition::Top: + transform.Look_At(_orbitCenter + Vector3(0.0f, 0.0f, _cameraDistance), _orbitCenter, 3.1415926535f); + break; + case CameraPosition::Bottom: + transform.Look_At(_orbitCenter + Vector3(0.0f, 0.0f, -_cameraDistance), _orbitCenter, 3.1415926535f); + break; + } + + _camera->Set_Transform(transform); + _rotation = Build_Quaternion(transform); + updateSceneLightPosition(old_center); +} + +void W3DViewport::resetCamera() +{ + if (_renderObject && !Is2DPreviewObject(_renderObject)) { + resetCameraToObject(*_renderObject); + } +} + +void W3DViewport::setAllowedCameraRotation(CameraRotation rotation) +{ + _allowedRotation = rotation; +} + +W3DViewport::CameraRotation W3DViewport::allowedCameraRotation() const +{ + return _allowedRotation; +} + +void W3DViewport::setAutoResetEnabled(bool enabled) +{ + _autoResetCamera = enabled; +} + +bool W3DViewport::isAutoResetEnabled() const +{ + return _autoResetCamera; +} + +void W3DViewport::requestOneTimeCameraReset() +{ + _oneTimeCameraReset = true; +} + +void W3DViewport::setCameraAnimationEnabled(bool enabled) +{ + _animateCamera = enabled; +} + +bool W3DViewport::isCameraAnimationEnabled() const +{ + return _animateCamera; +} + +void W3DViewport::setCameraBonePosX(bool enabled) +{ + _cameraBonePosX = enabled; +} + +bool W3DViewport::isCameraBonePosX() const +{ + return _cameraBonePosX; +} + +void W3DViewport::setAnimation(HAnimClass *animation) +{ + if (_animationCombo) { + delete _animationCombo; + _animationCombo = nullptr; + } + REF_PTR_RELEASE(_animation); + REF_PTR_SET(_animation, animation); + _animationTime = 0.0f; + _animationFrame = 0.0f; + _animationState = AnimationState::Playing; + + if (_renderObject && _animation) { + _renderObject->Set_Animation(_animation, 0); + } + emit animationStateChanged(); +} + +void W3DViewport::setAnimationCombo(HAnimComboClass *combo) +{ + if (_animationCombo) { + delete _animationCombo; + } + + _animationCombo = combo; + REF_PTR_RELEASE(_animation); + _animationTime = 0.0f; + _animationFrame = 0.0f; + _animationState = AnimationState::Playing; + + if (_animationCombo) { + _animation = _animationCombo->Get_Motion(0); + } + + if (_renderObject && _animationCombo) { + _renderObject->Set_Animation(_animationCombo); + } + emit animationStateChanged(); +} + +void W3DViewport::clearAnimation() +{ + _animationTime = 0.0f; + _animationFrame = 0.0f; + _animationState = AnimationState::Stopped; + REF_PTR_RELEASE(_animation); + if (_animationCombo) { + delete _animationCombo; + _animationCombo = nullptr; + } + if (_renderObject) { + _renderObject->Set_Animation(); + } + emit animationStateChanged(); +} + +bool W3DViewport::animationStatus(int ¤tFrame, int &totalFrames, float &fps) const +{ + if (!_animation) { + currentFrame = 0; + totalFrames = 0; + fps = 0.0f; + return false; + } + + totalFrames = _animation->Get_Num_Frames(); + currentFrame = static_cast(_animationFrame); + const float frame_rate = _animation->Get_Frame_Rate(); + fps = frame_rate * _animationSpeed; + return totalFrames > 0; +} + +float W3DViewport::averageFrameMilliseconds() const +{ + return _averageFrameMs; +} + +void W3DViewport::setBackgroundColor(const Vector3 &color) +{ + _clearColor = color; + if (_scene) { + _scene->Set_Fog_Color(color); + } +} + +Vector3 W3DViewport::backgroundColor() const +{ + return _clearColor; +} + +void W3DViewport::setBackgroundBitmap(const QString &path) +{ + _backgroundBitmap = path; + refreshBackgroundBitmap(); +} + +QString W3DViewport::backgroundBitmap() const +{ + return _backgroundBitmap; +} + +void W3DViewport::setBackgroundObjectName(const QString &name) +{ + _backgroundObjectName = name; + + if (_backgroundObject && _backgroundObjectScene) { + _backgroundObject->Remove(); + } + REF_PTR_RELEASE(_backgroundObject); + + if (name.trimmed().isEmpty() || !_backgroundObjectScene) { + return; + } + + auto *asset_manager = WW3DAssetManager::Get_Instance(); + if (!asset_manager) { + return; + } + + const QByteArray native = name.toLatin1(); + _backgroundObject = asset_manager->Create_Render_Obj(native.constData()); + if (!_backgroundObject) { + return; + } + + _backgroundObject->Set_Position(Vector3(0.0f, 0.0f, 0.0f)); + updateBackgroundObjectCamera(); + + _backgroundObjectScene->Add_Render_Object(_backgroundObject); +} + +QString W3DViewport::backgroundObjectName() const +{ + return _backgroundObjectName; +} + +void W3DViewport::setAmbientLight(const Vector3 &color) +{ + _ambientLight = color; + if (_scene) { + _scene->Set_Ambient_Light(color); + } +} + +Vector3 W3DViewport::ambientLight() const +{ + return _ambientLight; +} + +void W3DViewport::setFogEnabled(bool enabled) +{ + _fogEnabled = enabled; + if (_scene) { + _scene->Set_Fog_Enable(enabled); + } +} + +bool W3DViewport::isFogEnabled() const +{ + return _fogEnabled; +} + +void W3DViewport::updateBackgroundObjectCamera() +{ + if (!_backgroundObject || !_backgroundObjectCamera || !_camera) { + return; + } + + const SphereClass sphere = _backgroundObject->Get_Bounding_Sphere(); + const float radius = std::max(0.01f, sphere.Radius); + Matrix3D transform = _camera->Get_Transform(); + transform.Set_Translation(sphere.Center + transform.Get_Z_Vector() * (radius * 3.0f)); + _backgroundObjectCamera->Set_Transform(transform); + _backgroundObjectCamera->Set_Clip_Planes( + std::max(0.001f, radius * 0.01f), std::max(1.0f, radius * 8.0f)); +} + +bool W3DViewport::sceneFogRange(float &start, float &end) const +{ + if (!_scene) { + return false; + } + + _scene->Get_Fog_Range(&start, &end); + return true; +} + +void W3DViewport::setSceneLightColor(const Vector3 &color) +{ + setSceneLightDiffuse(color); + setSceneLightSpecular(color); +} + +Vector3 W3DViewport::sceneLightColor() const +{ + return sceneLightDiffuse(); +} + +void W3DViewport::setSceneLightDiffuse(const Vector3 &color) +{ + _sceneLightDiffuse = color; + if (_sceneLight) { + _sceneLight->Set_Diffuse(color); + } +} + +Vector3 W3DViewport::sceneLightDiffuse() const +{ + if (_sceneLight) { + Vector3 color; + _sceneLight->Get_Diffuse(&color); + return color; + } + + return _sceneLightDiffuse; +} + +void W3DViewport::setSceneLightSpecular(const Vector3 &color) +{ + _sceneLightSpecular = color; + if (_sceneLight) { + _sceneLight->Set_Specular(color); + } +} + +Vector3 W3DViewport::sceneLightSpecular() const +{ + if (_sceneLight) { + Vector3 color; + _sceneLight->Get_Specular(&color); + return color; + } + + return _sceneLightSpecular; +} + +void W3DViewport::setSceneLightOrientation(const Quaternion &orientation) +{ + _sceneLightOrientation = orientation; + _sceneLightOrientationSet = true; + if (!_sceneLight) { + return; + } + + float distance = sceneLightDistance(); + if (distance <= 0.0f) { + distance = std::max(1.0f, _cameraDistance); + } + + Matrix3D light_tm(1); + light_tm.Set_Translation(_orbitCenter); + Matrix3D::Multiply(light_tm, Build_Matrix3D(orientation), &light_tm); + light_tm.Translate(Vector3(0.0f, 0.0f, distance)); + _sceneLight->Set_Transform(light_tm); + syncLightMesh(); +} + +Quaternion W3DViewport::sceneLightOrientation() const +{ + if (_sceneLight) { + return Build_Quaternion(_sceneLight->Get_Transform()); + } + + return _sceneLightOrientation; +} + +void W3DViewport::setSceneLightDistance(float distance) +{ + _sceneLightDistance = distance; + _sceneLightDistanceSet = true; + if (!_sceneLight) { + return; + } + + Vector3 direction = _sceneLight->Get_Position() - _orbitCenter; + float length = direction.Length(); + if (length <= 0.0f) { + direction = Vector3(0.0f, 0.0f, 1.0f); + } else { + direction.Normalize(); + } + + _sceneLight->Set_Position(_orbitCenter + direction * distance); + syncLightMesh(); +} + +float W3DViewport::sceneLightDistance() const +{ + if (_sceneLight) { + return (_sceneLight->Get_Position() - _orbitCenter).Length(); + } + + return _sceneLightDistance; +} + +void W3DViewport::setSceneLightIntensity(float intensity) +{ + _sceneLightIntensity = intensity; + if (_sceneLight) { + _sceneLight->Set_Intensity(intensity); + } +} + +float W3DViewport::sceneLightIntensity() const +{ + if (_sceneLight) { + return _sceneLight->Get_Intensity(); + } + + return _sceneLightIntensity; +} + +void W3DViewport::setSceneLightAttenuation(float start, float end, bool enabled) +{ + _sceneLightAttenStart = start; + _sceneLightAttenEnd = end; + _sceneLightAttenEnabled = enabled; + if (_sceneLight) { + _sceneLight->Set_Far_Attenuation_Range(start, end); + _sceneLight->Set_Flag(LightClass::FAR_ATTENUATION, enabled); + } +} + +void W3DViewport::sceneLightAttenuation(float &start, float &end, bool &enabled) const +{ + if (_sceneLight) { + _sceneLight->Get_Far_Attenuation_Range(start, end); + enabled = _sceneLight->Get_Flag(LightClass::FAR_ATTENUATION) != 0; + return; + } + + start = _sceneLightAttenStart; + end = _sceneLightAttenEnd; + enabled = _sceneLightAttenEnabled; +} + +W3DViewport::SceneLightState W3DViewport::sceneLightState() const +{ + SceneLightState state; + state.diffuse = sceneLightDiffuse(); + state.specular = sceneLightSpecular(); + state.orientation = sceneLightOrientation(); + state.distance = sceneLightDistance(); + state.intensity = sceneLightIntensity(); + sceneLightAttenuation( + state.attenuationStart, state.attenuationEnd, state.attenuationEnabled); + state.orientationExplicit = _sceneLightOrientationSet; + state.distanceExplicit = _sceneLightDistanceSet; + return state; +} + +void W3DViewport::setSceneLightState(const SceneLightState &state) +{ + setSceneLightDiffuse(state.diffuse); + setSceneLightSpecular(state.specular); + setSceneLightIntensity(state.intensity); + setSceneLightAttenuation( + state.attenuationStart, state.attenuationEnd, state.attenuationEnabled); + + if (state.orientationExplicit) { + setSceneLightOrientation(state.orientation); + } + setSceneLightDistance(state.distance); + + _sceneLightOrientation = state.orientation; + _sceneLightDistance = state.distance; + _sceneLightOrientationSet = state.orientationExplicit; + _sceneLightDistanceSet = state.distanceExplicit; +} + +void W3DViewport::setWireframeEnabled(bool enabled) +{ + _wireframeEnabled = enabled; + if (_scene) { + _scene->Set_Polygon_Mode(enabled ? SceneClass::LINE : SceneClass::FILL); + } +} + +bool W3DViewport::isWireframeEnabled() const +{ + return _wireframeEnabled; +} + +void W3DViewport::setLodAutoSwitchingEnabled(bool enabled) +{ + _allowLodSwitching = enabled; + auto *scene = static_cast(_scene); + if (scene) { + scene->SetAllowLodSwitching(enabled); + } +} + +bool W3DViewport::isLodAutoSwitchingEnabled() const +{ + if (auto *scene = static_cast(_scene)) { + return scene->IsAllowLodSwitching(); + } + + return _allowLodSwitching; +} + +bool W3DViewport::currentLodInfo(int &level, int &count) const +{ + if (!_renderObject || _renderObject->Class_ID() != RenderObjClass::CLASSID_HLOD) { + return false; + } + + auto *hlod = static_cast(_renderObject); + level = hlod->Get_LOD_Level(); + count = hlod->Get_LOD_Count(); + return true; +} + +bool W3DViewport::setNullLodIncluded(bool enabled) +{ + if (!_renderObject || _renderObject->Class_ID() != RenderObjClass::CLASSID_HLOD) { + return false; + } + + auto *hlod = static_cast(_renderObject); + hlod->Include_NULL_Lod(enabled); + UpdateLodPrototype(*hlod); + return true; +} + +bool W3DViewport::isNullLodIncluded() const +{ + if (!_renderObject || _renderObject->Class_ID() != RenderObjClass::CLASSID_HLOD) { + return false; + } + + auto *hlod = static_cast(_renderObject); + return hlod->Is_NULL_Lod_Included(); +} + +bool W3DViewport::recordLodScreenArea() +{ + if (!_renderObject || !_camera || + _renderObject->Class_ID() != RenderObjClass::CLASSID_HLOD) { + return false; + } + + auto *hlod = static_cast(_renderObject); + const float screen_size = _renderObject->Get_Screen_Size(*_camera); + hlod->Set_Max_Screen_Size(hlod->Get_LOD_Level(), screen_size); + UpdateLodPrototype(*hlod); + return true; +} + +bool W3DViewport::adjustLodLevel(int delta) +{ + bool switched = false; + SwitchLod(_renderObject, delta, switched); + return switched; +} + +void W3DViewport::applySceneLightSettings() +{ + if (!_sceneLight) { + return; + } + + setSceneLightDiffuse(_sceneLightDiffuse); + setSceneLightSpecular(_sceneLightSpecular); + setSceneLightIntensity(_sceneLightIntensity); + setSceneLightAttenuation(_sceneLightAttenStart, _sceneLightAttenEnd, _sceneLightAttenEnabled); + + if (_sceneLightOrientationSet) { + setSceneLightOrientation(_sceneLightOrientation); + } + + if (_sceneLightDistanceSet) { + setSceneLightDistance(_sceneLightDistance); + } +} + +void W3DViewport::updateSceneLightPosition(const Vector3 &oldCenter) +{ + if (!_sceneLight) { + return; + } + + Vector3 direction = _sceneLight->Get_Position() - oldCenter; + float distance = direction.Length(); + if (distance <= 0.0f) { + direction = Vector3(0.0f, 0.0f, 1.0f); + distance = std::max(1.0f, _cameraDistance); + } else { + direction.Normalize(); + } + + _sceneLight->Set_Position(_orbitCenter + direction * distance); + _sceneLightDistance = distance; + syncLightMesh(); +} + +void W3DViewport::syncLightMesh() +{ + if (!_lightMesh || !_sceneLight) { + return; + } + + _lightMesh->Set_Transform(_sceneLight->Get_Transform()); + + const float view_distance = std::max(1.0f, _cameraDistance); + const float desired_scale = view_distance / 14.0f; + if (_lightMeshScale > 0.0f && desired_scale != _lightMeshScale) { + _lightMesh->Scale(desired_scale / _lightMeshScale); + _lightMeshScale = desired_scale; + } +} + +void W3DViewport::setLightMeshVisible(bool visible) +{ + if (!_scene || !_lightMesh || visible == _lightMeshInScene) { + return; + } + + if (visible) { + _scene->Add_Render_Object(_lightMesh); + } else { + _scene->Remove_Render_Object(_lightMesh); + } + _lightMeshInScene = visible; +} + +void W3DViewport::updateInteractionCursor() +{ + if (_leftDown && _rightDown) { + setCursor(Qt::ClosedHandCursor); + } else if (_leftDown) { + setCursor(Qt::OpenHandCursor); + } else if (_rightDown) { + setCursor(Qt::SizeVerCursor); + } else { + unsetCursor(); + } +} + +void W3DViewport::refreshBackgroundBitmap() +{ + if (!_backgroundScene) { + return; + } + + if (_backgroundBitmapObj) { + _backgroundBitmapObj->Remove(); + _backgroundBitmapObj->Release_Ref(); + _backgroundBitmapObj = nullptr; + } + + if (_backgroundBitmap.trimmed().isEmpty()) { + return; + } + + const QByteArray native = QDir::toNativeSeparators(_backgroundBitmap).toLocal8Bit(); + _backgroundBitmapObj = NEW_REF(Bitmap2DObjClass, (native.constData(), 0.5f, 0.5f, true, false)); + if (_backgroundBitmapObj) { + _backgroundScene->Add_Render_Object(_backgroundBitmapObj); + } +} + +void W3DViewport::updateAnimation(float deltaSeconds) +{ + if (!_animation || !_renderObject) { + return; + } + + if (_animationState != AnimationState::Playing) { + return; + } + + const int total_frames = _animation->Get_Num_Frames(); + const float frame_rate = _animation->Get_Frame_Rate(); + if (total_frames <= 1 || frame_rate <= 0.0f) { + _renderObject->Set_Animation(_animation, 0); + return; + } + + const float loop_time = static_cast(total_frames - 1) / frame_rate; + _animationTime += deltaSeconds * _animationSpeed; + if (_animationTime > loop_time) { + _animationTime = std::fmod(_animationTime, loop_time); + } + + _animationFrame = frame_rate * _animationTime; + if (_animationCombo) { + const int count = _animationCombo->Get_Num_Anims(); + for (int index = 0; index < count; ++index) { + _animationCombo->Set_Frame(index, _animationFrame); + } + _renderObject->Set_Animation(_animationCombo); + } else { + if (_animationBlend) { + _renderObject->Set_Animation(_animation, _animationFrame); + } else { + _renderObject->Set_Animation(_animation, static_cast(_animationFrame)); + } + } +} + +void W3DViewport::setAnimationState(AnimationState state) +{ + if (_animationState == state) { + return; + } + + _animationState = state; + emit animationStateChanged(); + + if (!_renderObject || !_animation) { + return; + } + + if (state == AnimationState::Stopped) { + _animationTime = 0.0f; + _animationFrame = 0.0f; + if (_animationCombo) { + const int count = _animationCombo->Get_Num_Anims(); + for (int index = 0; index < count; ++index) { + _animationCombo->Set_Frame(index, 0.0f); + } + _renderObject->Set_Animation(_animationCombo); + } else { + _renderObject->Set_Animation(_animation, 0); + } + } +} + +W3DViewport::AnimationState W3DViewport::animationState() const +{ + return _animationState; +} + +void W3DViewport::setAnimationSpeed(float speed) +{ + if (speed <= 0.0f) { + speed = 0.01f; + } + _animationSpeed = speed; +} + +float W3DViewport::animationSpeed() const +{ + return _animationSpeed; +} + +void W3DViewport::setAnimationBlend(bool enabled) +{ + if (_animationBlend == enabled) { + return; + } + + _animationBlend = enabled; + if (!_renderObject || !_animation || _animationCombo) { + return; + } + + if (_animationBlend) { + _renderObject->Set_Animation(_animation, _animationFrame); + } else { + _renderObject->Set_Animation(_animation, static_cast(_animationFrame)); + } +} + +bool W3DViewport::animationBlend() const +{ + return _animationBlend; +} + +bool W3DViewport::stepAnimation(int delta) +{ + if (!_animation || !_renderObject) { + return false; + } + + const int total_frames = _animation->Get_Num_Frames(); + if (total_frames <= 1) { + if (_animationCombo) { + const int count = _animationCombo->Get_Num_Anims(); + for (int index = 0; index < count; ++index) { + _animationCombo->Set_Frame(index, 0.0f); + } + _renderObject->Set_Animation(_animationCombo); + } else { + _renderObject->Set_Animation(_animation, 0); + } + _animationFrame = 0.0f; + _animationTime = 0.0f; + return true; + } + + int frame = static_cast(_animationFrame) + delta; + if (frame >= total_frames) { + frame = 0; + } else if (frame < 0) { + frame = total_frames - 1; + } + + _animationFrame = static_cast(frame); + const float frame_rate = _animation->Get_Frame_Rate(); + if (frame_rate > 0.0f) { + _animationTime = _animationFrame / frame_rate; + } else { + _animationTime = 0.0f; + } + if (_animationCombo) { + const int count = _animationCombo->Get_Num_Anims(); + for (int index = 0; index < count; ++index) { + _animationCombo->Set_Frame(index, _animationFrame); + } + _renderObject->Set_Animation(_animationCombo); + } else { + _renderObject->Set_Animation(_animation, frame); + } + return true; +} + +void W3DViewport::applyAnimationFrame(float frame) +{ + _animationFrame = frame; + if (!_animation || !_renderObject) { + return; + } + + const float frame_rate = _animation->Get_Frame_Rate(); + if (frame_rate > 0.0f) { + _animationTime = _animationFrame / frame_rate; + } else { + _animationTime = 0.0f; + } + + if (_animationCombo) { + const int count = _animationCombo->Get_Num_Anims(); + for (int index = 0; index < count; ++index) { + _animationCombo->Set_Frame(index, _animationFrame); + } + _renderObject->Set_Animation(_animationCombo); + } else { + _renderObject->Set_Animation(_animation, _animationFrame); + } +} + +bool W3DViewport::hasAnimation() const +{ + return _animation != nullptr; +} + +QString W3DViewport::currentAnimationName() const +{ + return _animation && _animation->Get_Name() + ? QString::fromLatin1(_animation->Get_Name()) + : QString(); +} + +bool W3DViewport::captureMovie(const QString &baseName, float frameRate, QString *error) +{ + if (error) { + error->clear(); + } + + if (!_renderObject || !_animation) { + if (error) { + *error = "No animation is available for capture."; + } + return false; + } + + if (frameRate <= 0.0f) { + frameRate = 30.0f; + } + + const int total_frames = _animation->Get_Num_Frames(); + const float anim_rate = _animation->Get_Frame_Rate(); + if (total_frames <= 1 || anim_rate <= 0.0f) { + if (error) { + *error = "Animation has no frames to capture."; + } + return false; + } + + const bool timer_active = _timer.isActive(); + if (timer_active) { + _timer.stop(); + } + + const AnimationState prev_state = _animationState; + const float prev_frame = _animationFrame; + + setAnimationState(AnimationState::Paused); + applyAnimationFrame(0.0f); + + const QByteArray base_bytes = baseName.trimmed().isEmpty() + ? QByteArray("Grab") + : baseName.toLatin1(); + + WW3D::Pause_Movie(true); + bool capture_ok = WW3D::Try_Start_Movie_Capture(base_bytes.constData(), frameRate); + if (capture_ok) { + WW3D::Pause_Movie(true); + } else if (error) { + *error = "Unable to create the AVI movie capture file."; + } + + const float frame_inc = anim_rate / frameRate; + const int ticks = static_cast(1000.0f / frameRate); + + for (float frame = 0.0f; + capture_ok && frame <= (static_cast(total_frames) - 1.0f); + frame += frame_inc) { + applyAnimationFrame(frame); + // Capture the unpresented render-device back buffer so movie output is + // independent of desktop occlusion and the viewport's screen position. + renderFrameWithTicks(ticks, false); + if (!WW3D::Try_Update_Movie_Capture_From_Back_Buffer()) { + capture_ok = false; + if (error) { + *error = "Unable to capture or write an AVI movie frame."; + } + break; + } + renderScene(true); +#ifdef _WIN32 + if (::GetAsyncKeyState(VK_ESCAPE) < 0) { + break; + } +#endif + } + + WW3D::Stop_Movie_Capture(); + + if (prev_state == AnimationState::Stopped) { + setAnimationState(AnimationState::Stopped); + } else { + applyAnimationFrame(prev_frame); + _animationState = prev_state; + emit animationStateChanged(); + } + + if (timer_active) { + _elapsed.restart(); + _timer.start(); + } + + return capture_ok; +} + +bool W3DViewport::toggleSubobjectLod() +{ + if (!_renderObject) { + return false; + } + + const bool enabled = _renderObject->Is_Sub_Objects_Match_LOD_Enabled() != 0; + _renderObject->Set_Sub_Objects_Match_LOD(!enabled); + UpdateAggregatePrototype(*_renderObject); + return !enabled; +} + +bool W3DViewport::isSubobjectLodBound() const +{ + if (!_renderObject) { + return false; + } + + return _renderObject->Is_Sub_Objects_Match_LOD_Enabled() != 0; +} + +void W3DViewport::updateCameraAnimation() +{ + if (!_animateCamera || !_renderObject || !_camera) { + return; + } + + Matrix3D bone_transform(1); + if (!GetCameraTransform(_renderObject, bone_transform)) { + return; + } + + const Matrix3D camera_transform = BuildCameraBoneTransform(bone_transform); + _camera->Set_Transform(camera_transform); +} + +void W3DViewport::updateObjectRotation() +{ + if (!_renderObject || _objectRotation == RotateNone) { + return; + } + + Matrix3D transform = _renderObject->Get_Transform(); + + if (_objectRotation & RotateX) { + transform.Rotate_X(0.05f); + } else if (_objectRotation & RotateXBack) { + transform.Rotate_X(-0.05f); + } + if (_objectRotation & RotateY) { + transform.Rotate_Y(-0.05f); + } else if (_objectRotation & RotateYBack) { + transform.Rotate_Y(0.05f); + } + if (_objectRotation & RotateZ) { + transform.Rotate_Z(0.05f); + } else if (_objectRotation & RotateZBack) { + transform.Rotate_Z(-0.05f); + } + + if (!transform.Is_Orthogonal()) { + transform.Re_Orthogonalize(); + } + + _renderObject->Set_Transform(transform); +} + +void W3DViewport::updateLightRotation() +{ + if (!_sceneLight || !_renderObject || _lightRotation == RotateNone) { + return; + } + + Matrix3D rotation_matrix(1); + if (_lightRotation & RotateX) { + rotation_matrix.Rotate_X(0.05f); + } else if (_lightRotation & RotateXBack) { + rotation_matrix.Rotate_X(-0.05f); + } + if (_lightRotation & RotateY) { + rotation_matrix.Rotate_Y(-0.05f); + } else if (_lightRotation & RotateYBack) { + rotation_matrix.Rotate_Y(0.05f); + } + if (_lightRotation & RotateZ) { + rotation_matrix.Rotate_Z(0.05f); + } else if (_lightRotation & RotateZBack) { + rotation_matrix.Rotate_Z(-0.05f); + } + + Matrix3D coord_inv; + Matrix3D coord_to_obj; + Matrix3D coord_system = _renderObject->Get_Transform(); + coord_system.Get_Orthogonal_Inverse(coord_inv); + + Matrix3D transform = _sceneLight->Get_Transform(); + Matrix3D::Multiply(coord_inv, transform, &coord_to_obj); + Matrix3D::Multiply(coord_system, rotation_matrix, &transform); + Matrix3D::Multiply(transform, coord_to_obj, &transform); + + if (!transform.Is_Orthogonal()) { + transform.Re_Orthogonalize(); + } + + _sceneLight->Set_Transform(transform); + _sceneLightOrientation = Build_Quaternion(transform); + _sceneLightOrientationSet = true; + _sceneLightDistance = sceneLightDistance(); + _sceneLightDistanceSet = true; + syncLightMesh(); +} diff --git a/Code/Tools/W3DViewQt/W3DViewport.h b/Code/Tools/W3DViewQt/W3DViewport.h new file mode 100644 index 000000000..7932d595c --- /dev/null +++ b/Code/Tools/W3DViewQt/W3DViewport.h @@ -0,0 +1,289 @@ +#pragma once + +#include "quat.h" + +#include +#include +#include +#include +#include + +class CameraClass; +class DazzleLayerClass; +class HAnimClass; +class HAnimComboClass; +class LightClass; +class Bitmap2DObjClass; +class QFocusEvent; +class QKeyEvent; +class RenderObjClass; +class SceneClass; + +class W3DViewport final : public QWidget +{ + Q_OBJECT + +public: + enum ObjectRotation { + RotateNone = 0, + RotateX = 1 << 0, + RotateY = 1 << 1, + RotateZ = 1 << 2, + RotateXBack = 1 << 3, + RotateYBack = 1 << 4, + RotateZBack = 1 << 5, + }; + + enum class CameraPosition { + Front, + Back, + Left, + Right, + Top, + Bottom, + }; + + enum class CameraRotation { + Free, + OnlyX, + OnlyY, + OnlyZ, + }; + + enum class AnimationState { + Playing, + Stopped, + Paused, + }; + + struct SceneLightState { + Vector3 diffuse = Vector3(1.0f, 1.0f, 1.0f); + Vector3 specular = Vector3(1.0f, 1.0f, 1.0f); + Quaternion orientation = Quaternion(true); + float distance = 0.0f; + float intensity = 1.0f; + float attenuationStart = 1000000.0f; + float attenuationEnd = 1000000.0f; + bool attenuationEnabled = false; + bool orientationExplicit = false; + bool distanceExplicit = false; + }; + + explicit W3DViewport(QWidget *parent = nullptr); + ~W3DViewport() override; + void setRenderObject(RenderObjClass *object); + RenderObjClass *currentRenderObject() const { return _renderObject; } + bool animationStatus(int ¤tFrame, int &totalFrames, float &fps) const; + float averageFrameMilliseconds() const; + void setCameraPosition(CameraPosition position); + void resetCamera(); + void setAllowedCameraRotation(CameraRotation rotation); + CameraRotation allowedCameraRotation() const; + void setAutoResetEnabled(bool enabled); + bool isAutoResetEnabled() const; + void requestOneTimeCameraReset(); + void setCameraAnimationEnabled(bool enabled); + bool isCameraAnimationEnabled() const; + void setCameraBonePosX(bool enabled); + bool isCameraBonePosX() const; + void setManualFovEnabled(bool enabled); + bool isManualFovEnabled() const; + void setManualClipPlanesEnabled(bool enabled); + bool isManualClipPlanesEnabled() const; + void setCameraFovDegrees(double hfov_deg, double vfov_deg); + void cameraFovDegrees(double &hfov_deg, double &vfov_deg) const; + void setCameraClipPlanes(float znear, float zfar); + void cameraClipPlanes(float &znear, float &zfar) const; + void resetFov(); + void setCameraDistance(float distance); + float cameraDistance() const; + float currentScreenSize() const; + void setInitialDisplayMode(int width, int height, int bitsPerPixel, bool fullscreen); + bool applyResolution(int width, int height, int bitsPerPixel, bool fullscreen); + bool addToLineup(RenderObjClass *object); + bool canLineUpClass(int class_id) const; + void clearLineup(); + void setObjectRotationFlags(int flags); + int objectRotationFlags() const; + void resetObjectTransform(); + void toggleAlternateMaterials(); + void setLightRotationFlags(int flags); + int lightRotationFlags() const; + void setBackgroundColor(const Vector3 &color); + Vector3 backgroundColor() const; + void setBackgroundBitmap(const QString &path); + QString backgroundBitmap() const; + void setBackgroundObjectName(const QString &name); + QString backgroundObjectName() const; + void setAmbientLight(const Vector3 &color); + Vector3 ambientLight() const; + void setFogEnabled(bool enabled); + bool isFogEnabled() const; + bool sceneFogRange(float &start, float &end) const; + void setAnimationState(AnimationState state); + AnimationState animationState() const; + void setAnimationSpeed(float speed); + float animationSpeed() const; + void setAnimationBlend(bool enabled); + bool animationBlend() const; + bool stepAnimation(int delta); + bool hasAnimation() const; + QString currentAnimationName() const; + bool toggleSubobjectLod(); + bool isSubobjectLodBound() const; + void setSceneLightDiffuse(const Vector3 &color); + Vector3 sceneLightDiffuse() const; + void setSceneLightSpecular(const Vector3 &color); + Vector3 sceneLightSpecular() const; + void setSceneLightColor(const Vector3 &color); + Vector3 sceneLightColor() const; + void setSceneLightOrientation(const Quaternion &orientation); + Quaternion sceneLightOrientation() const; + void setSceneLightDistance(float distance); + float sceneLightDistance() const; + void setSceneLightIntensity(float intensity); + float sceneLightIntensity() const; + void setSceneLightAttenuation(float start, float end, bool enabled); + void sceneLightAttenuation(float &start, float &end, bool &enabled) const; + SceneLightState sceneLightState() const; + void setSceneLightState(const SceneLightState &state); + void setAnimation(HAnimClass *animation); + void setAnimationCombo(HAnimComboClass *combo); + void clearAnimation(); + void setWireframeEnabled(bool enabled); + bool isWireframeEnabled() const; + void setLodAutoSwitchingEnabled(bool enabled); + bool isLodAutoSwitchingEnabled() const; + bool currentLodInfo(int &level, int &count) const; + bool setNullLodIncluded(bool enabled); + bool isNullLodIncluded() const; + bool recordLodScreenArea(); + bool adjustLodLevel(int delta); + int captureScreenshot(const QString &basePath); + bool captureMovie(const QString &baseName, float frameRate, QString *error = nullptr); + +signals: + void animationStateChanged(); + void objectCameraReset(); + +protected: + QPaintEngine *paintEngine() const override; + void showEvent(QShowEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + void hideEvent(QHideEvent *event) override; + void paintEvent(QPaintEvent *event) override; + void focusOutEvent(QFocusEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + void keyReleaseEvent(QKeyEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void wheelEvent(QWheelEvent *event) override; + +private slots: + void renderFrame(); + +private: + void initWW3D(); + void shutdownWW3D(); + void initScene(); + void shutdownScene(); + void addRenderObjectToDisplayScene(RenderObjClass *object); + void removeRenderObjectFromDisplayScene(RenderObjClass *object); + void renderScene(bool present = true); + void renderFrameWithTicks(int ticks, bool present = true); + bool trySetDeviceResolution(int width, + int height, + int bitsPerPixel, + int &actualWidth, + int &actualHeight, + int &actualBitsPerPixel); + void updateCameraFov(int width, int height, bool force = false); + void resetCameraToObject(RenderObjClass &object); + void setFogNearAndRecalculate(float nearClip); + void applySceneLightSettings(); + void updateSceneLightPosition(const Vector3 &oldCenter); + void updateBackgroundObjectCamera(); + void syncLightMesh(); + void setLightMeshVisible(bool visible); + void updateInteractionCursor(); + void updateAnimation(float deltaSeconds); + void updateCameraAnimation(); + void updateObjectRotation(); + void updateLightRotation(); + void refreshBackgroundBitmap(); + void applyAnimationFrame(float frame); + void updateFrameTiming(float elapsedMs); + + QTimer _timer; + QElapsedTimer _elapsed; + float _frameTimeAccumMs = 0.0f; + int _frameTimeSamples = 0; + float _averageFrameMs = 0.0f; + bool _initialized = false; + SceneClass *_scene = nullptr; + CameraClass *_camera = nullptr; + LightClass *_sceneLight = nullptr; + RenderObjClass *_lightMesh = nullptr; + DazzleLayerClass *_dazzleLayer = nullptr; + RenderObjClass *_renderObject = nullptr; + HAnimClass *_animation = nullptr; + HAnimComboClass *_animationCombo = nullptr; + float _animationFrame = 0.0f; + float _animationTime = 0.0f; + bool _animationBlend = true; + SceneClass *_backgroundScene = nullptr; + CameraClass *_backgroundCamera = nullptr; + Bitmap2DObjClass *_backgroundBitmapObj = nullptr; + SceneClass *_preview2DScene = nullptr; + CameraClass *_preview2DCamera = nullptr; + SceneClass *_backgroundObjectScene = nullptr; + CameraClass *_backgroundObjectCamera = nullptr; + RenderObjClass *_backgroundObject = nullptr; + Vector3 _clearColor = Vector3(0.5f, 0.5f, 0.5f); + QString _backgroundBitmap; + QString _backgroundObjectName; + Vector3 _ambientLight = Vector3(0.5f, 0.5f, 0.5f); + Vector3 _sceneLightDiffuse = Vector3(1.0f, 1.0f, 1.0f); + Vector3 _sceneLightSpecular = Vector3(1.0f, 1.0f, 1.0f); + Quaternion _sceneLightOrientation = Quaternion(true); + float _sceneLightDistance = 0.0f; + float _sceneLightIntensity = 1.0f; + float _sceneLightAttenStart = 1000000.0f; + float _sceneLightAttenEnd = 1000000.0f; + bool _sceneLightAttenEnabled = false; + bool _sceneLightOrientationSet = false; + bool _sceneLightDistanceSet = false; + bool _lightMeshInScene = false; + float _lightMeshScale = 1.0f; + bool _fogEnabled = false; + bool _wireframeEnabled = false; + bool _autoResetCamera = true; + bool _oneTimeCameraReset = true; + bool _animateCamera = false; + bool _cameraBonePosX = false; + bool _manualFov = false; + bool _manualClipPlanes = false; + CameraRotation _allowedRotation = CameraRotation::Free; + bool _windowed = true; + bool _fullscreen = false; + bool _leftDown = false; + bool _rightDown = false; + QPoint _lastPos; + float _cameraDistance = 0.0f; + float _minZoomAdjust = 0.0f; + Vector3 _orbitCenter = Vector3(0.0f, 0.0f, 0.0f); + Quaternion _rotation = Quaternion(true); + int _objectRotation = RotateNone; + int _lightRotation = RotateNone; + double _manualHfov = 0.0; + double _manualVfov = 0.0; + float _manualNear = 0.2f; + float _manualFar = 10000.0f; + int _initialDisplayWidth = 0; + int _initialDisplayHeight = 0; + int _bitsPerPixel = 32; + bool _allowLodSwitching = false; + AnimationState _animationState = AnimationState::Playing; + float _animationSpeed = 1.0f; +}; diff --git a/Code/Tools/W3DViewQt/main.cpp b/Code/Tools/W3DViewQt/main.cpp new file mode 100644 index 000000000..9f4980346 --- /dev/null +++ b/Code/Tools/W3DViewQt/main.cpp @@ -0,0 +1,320 @@ +#include "MainWindow.h" + +#include "AnimatedSoundOptionsDialog.h" +#include "WWAudio.h" +#include "assetmgr.h" +#include "wwmath.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#ifdef _WIN32 +#include +#endif + +namespace { + +QStringList CollectStartupFiles(const QStringList &arguments) +{ + QStringList files; + for (int index = 1; index < arguments.size(); ++index) { + const QFileInfo info(arguments[index]); + if (info.suffix().compare("w3d", Qt::CaseInsensitive) == 0) { + files.append(info.absoluteFilePath()); + } + } + return files; +} + +#ifdef _WIN32 + +class WindowsSingleInstance final +{ +public: + WindowsSingleInstance() + { + _mutex = ::CreateMutexW(nullptr, FALSE, MutexName()); + if (_mutex && ::GetLastError() == ERROR_ALREADY_EXISTS) { + _primary = false; + return; + } + + _primary = true; + createMessageWindow(); + } + + ~WindowsSingleInstance() + { + if (_messageWindow) { + ::DestroyWindow(_messageWindow); + } + if (_windowClass) { + ::UnregisterClassW(WindowClassName(), ::GetModuleHandleW(nullptr)); + } + if (_mutex) { + ::CloseHandle(_mutex); + } + } + + bool isPrimary() const + { + return _primary; + } + + bool forwardToPrimary(const QStringList &files) const + { + HWND receiver = nullptr; + for (int attempt = 0; attempt < 100 && !receiver; ++attempt) { + receiver = ::FindWindowExW(HWND_MESSAGE, nullptr, WindowClassName(), nullptr); + if (!receiver) { + ::Sleep(50); + } + } + if (!receiver) { + return false; + } + + DWORD primary_process_id = 0; + ::GetWindowThreadProcessId(receiver, &primary_process_id); + if (primary_process_id != 0) { + ::AllowSetForegroundWindow(primary_process_id); + } + + const QString payload = files.join(QLatin1Char('\n')); + COPYDATASTRUCT copy_data = {}; + copy_data.dwData = CopyDataId(); + copy_data.cbData = static_cast((payload.size() + 1) * sizeof(wchar_t)); + copy_data.lpData = const_cast(payload.utf16()); + + DWORD_PTR response = 0; + const LRESULT sent = ::SendMessageTimeoutW( + receiver, + WM_COPYDATA, + 0, + reinterpret_cast(©_data), + SMTO_ABORTIFHUNG | SMTO_BLOCK, + 5000, + &response); + return sent != 0 && response != 0; + } + + void attach(W3DViewMainWindow *window) + { + _window = window; + if (!_activationPending && _pendingFiles.isEmpty()) { + return; + } + + const QStringList files = _pendingFiles; + _pendingFiles.clear(); + _activationPending = false; + activateAndOpen(files); + } + +private: + static const wchar_t *MutexName() + { + return L"Local\\OpenW3D.W3DViewQt.SingleInstance.v1"; + } + + static const wchar_t *WindowClassName() + { + return L"OpenW3D.W3DViewQt.SingleInstance.MessageWindow.v1"; + } + + static ULONG_PTR CopyDataId() + { + return static_cast(0x57334451U); // "W3DQ" + } + + void createMessageWindow() + { + WNDCLASSEXW window_class = {}; + window_class.cbSize = sizeof(window_class); + window_class.lpfnWndProc = &WindowsSingleInstance::WindowProc; + window_class.hInstance = ::GetModuleHandleW(nullptr); + window_class.lpszClassName = WindowClassName(); + _windowClass = ::RegisterClassExW(&window_class); + if (!_windowClass) { + return; + } + + _messageWindow = ::CreateWindowExW( + 0, + WindowClassName(), + L"", + 0, + 0, + 0, + 0, + 0, + HWND_MESSAGE, + nullptr, + window_class.hInstance, + this); + } + + static LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) + { + auto *instance = reinterpret_cast( + ::GetWindowLongPtrW(hwnd, GWLP_USERDATA)); + if (message == WM_NCCREATE) { + const auto *create = reinterpret_cast(lparam); + instance = static_cast(create->lpCreateParams); + ::SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(instance)); + } + + if (message == WM_COPYDATA && instance) { + const auto *copy_data = reinterpret_cast(lparam); + return instance->receiveCopyData(copy_data) ? TRUE : FALSE; + } + + if (message == WM_NCDESTROY) { + ::SetWindowLongPtrW(hwnd, GWLP_USERDATA, 0); + } + return ::DefWindowProcW(hwnd, message, wparam, lparam); + } + + bool receiveCopyData(const COPYDATASTRUCT *copy_data) + { + if (!copy_data || copy_data->dwData != CopyDataId() || !copy_data->lpData || + copy_data->cbData < sizeof(wchar_t) || + (copy_data->cbData % sizeof(wchar_t)) != 0) { + return false; + } + + const auto *text = static_cast(copy_data->lpData); + const qsizetype character_count = + static_cast(copy_data->cbData / sizeof(wchar_t)); + if (text[character_count - 1] != L'\0') { + return false; + } + + const QString payload = QString::fromWCharArray(text, character_count - 1); + const QStringList files = payload.split(QLatin1Char('\n'), Qt::SkipEmptyParts); + activateAndOpen(files); + return true; + } + + void activateAndOpen(const QStringList &files) + { + if (!_window) { + _activationPending = true; + _pendingFiles.append(files); + return; + } + + if (_window->isMinimized()) { + _window->showNormal(); + } else { + _window->show(); + } + const HWND hwnd = reinterpret_cast(_window->winId()); + ::BringWindowToTop(hwnd); + ::SetForegroundWindow(hwnd); + _window->raise(); + _window->activateWindow(); + + if (files.isEmpty()) { + return; + } + + const QPointer window(_window); + QTimer::singleShot(0, _window, [window, files]() { + if (!window) { + return; + } + for (const QString &path : files) { + const QFileInfo info(path); + if (info.suffix().compare("w3d", Qt::CaseInsensitive) == 0) { + window->openFilePath(info.absoluteFilePath()); + } + } + }); + } + + HANDLE _mutex = nullptr; + ATOM _windowClass = 0; + HWND _messageWindow = nullptr; + QPointer _window; + QStringList _pendingFiles; + bool _primary = true; + bool _activationPending = false; +}; + +#endif + +} // namespace + +int main(int argc, char *argv[]) +{ + int result = 0; + { + QApplication app(argc, argv); + QCoreApplication::setOrganizationName("OpenW3D"); + QCoreApplication::setApplicationName("W3DViewQt"); + QApplication::setWindowIcon(QIcon(":/w3dview/app.ico")); + + const QStringList startup_files = CollectStartupFiles(app.arguments()); + +#ifdef _WIN32 + WindowsSingleInstance single_instance; + if (!single_instance.isPrimary()) { + if (single_instance.forwardToPrimary(startup_files)) { + return EXIT_SUCCESS; + } + QMessageBox::critical(nullptr, + "W3DViewQt", + "Unable to contact the running W3DViewQt instance."); + return EXIT_FAILURE; + } +#endif + + WWMath::Init(); + AnimatedSoundOptionsDialog::LoadAnimatedSoundSettings(); + + { + WW3DAssetManager asset_manager; + asset_manager.Set_WW3D_Load_On_Demand(true); + asset_manager.Set_Activate_Fog_On_Load(true); + + WWAudioClass *audio_mgr = WWAudioClass::Create_Instance(); + if (audio_mgr) { + audio_mgr->Initialize(); + } + + { + W3DViewMainWindow window; + window.show(); + +#ifdef _WIN32 + single_instance.attach(&window); +#endif + if (!startup_files.isEmpty()) { + QTimer::singleShot(0, &window, [&window, startup_files]() { + for (const QString &path : startup_files) { + window.openFilePath(path); + } + }); + } + + result = app.exec(); + } + + if (audio_mgr) { + delete audio_mgr; + audio_mgr = nullptr; + } + } + } + + WWMath::Shutdown(); + return result; +} diff --git a/Code/Tools/W3DViewQt/tests/BackgroundObjectDialogTests.cpp b/Code/Tools/W3DViewQt/tests/BackgroundObjectDialogTests.cpp new file mode 100644 index 000000000..365f1034c --- /dev/null +++ b/Code/Tools/W3DViewQt/tests/BackgroundObjectDialogTests.cpp @@ -0,0 +1,108 @@ +#include "BackgroundObjectDialog.h" + +#include "assetmgr.h" +#include "proto.h" +#include "rendobj.h" + +#include +#include +#include +#include +#include + +namespace { +class TestPrototype final : public PrototypeClass +{ +public: + explicit TestPrototype(const QByteArray &name) + : _name(name) + { + } + + const char *Get_Name() const override { return _name.constData(); } + int Get_Class_ID() const override { return RenderObjClass::CLASSID_HMODEL; } + RenderObjClass *Create() override { return nullptr; } + +private: + QByteArray _name; +}; + +void addPrototype(WW3DAssetManager &assetManager, const char *name) +{ + assetManager.Add_Prototype(new TestPrototype(name)); +} + +QListWidget *objectList(BackgroundObjectDialog &dialog) +{ + auto *list = dialog.findChild("listWidget"); + if (!list) { + QTest::qFail("listWidget was not created from the Designer form", __FILE__, __LINE__); + } + return list; +} + +QStringList itemTexts(const QListWidget &list) +{ + QStringList texts; + for (int row = 0; row < list.count(); ++row) { + texts.push_back(list.item(row)->text()); + } + return texts; +} +} // namespace + +class BackgroundObjectDialogTests final : public QObject +{ + Q_OBJECT + +private slots: + void sortsObjectsAndRestoresCurrentSelection(); + void clearRemovesSelectionAndCurrentObject(); +}; + +void BackgroundObjectDialogTests::sortsObjectsAndRestoresCurrentSelection() +{ + WW3DAssetManager assetManager; + addPrototype(assetManager, "zulu"); + addPrototype(assetManager, "Charlie"); + addPrototype(assetManager, "alpha"); + addPrototype(assetManager, "Bravo"); + + BackgroundObjectDialog dialog("Charlie"); + QListWidget *list = objectList(dialog); + QVERIFY(list); + + QCOMPARE(itemTexts(*list), QStringList({"alpha", "Bravo", "Charlie", "zulu"})); + QVERIFY(list->currentItem()); + QCOMPARE(list->currentItem()->text(), QString("Charlie")); + QVERIFY(list->currentItem()->isSelected()); + QCOMPARE(dialog.selectedName(), QString("Charlie")); +} + +void BackgroundObjectDialogTests::clearRemovesSelectionAndCurrentObject() +{ + WW3DAssetManager assetManager; + addPrototype(assetManager, "ObjectB"); + addPrototype(assetManager, "ObjectA"); + + BackgroundObjectDialog dialog("ObjectB"); + QListWidget *list = objectList(dialog); + QVERIFY(list); + QCOMPARE(dialog.selectedName(), QString("ObjectB")); + + auto *clearButton = dialog.findChild("clearButton"); + QVERIFY(clearButton); + clearButton->click(); + + QVERIFY(list->selectedItems().isEmpty()); + QCOMPARE(list->currentRow(), -1); + QVERIFY(dialog.selectedName().isEmpty()); + + auto *currentLabel = dialog.findChild("currentLabel"); + QVERIFY(currentLabel); + QCOMPARE(currentLabel->text(), QString("Current Object: (none)")); +} + +QTEST_MAIN(BackgroundObjectDialogTests) + +#include "BackgroundObjectDialogTests.moc" diff --git a/Code/Tools/W3DViewQt/tests/EmitterEditDialogTests.cpp b/Code/Tools/W3DViewQt/tests/EmitterEditDialogTests.cpp new file mode 100644 index 000000000..ba4b0cc82 --- /dev/null +++ b/Code/Tools/W3DViewQt/tests/EmitterEditDialogTests.cpp @@ -0,0 +1,750 @@ +#include "EmitterEditDialog.h" + +#include "chunkio.h" +#include "part_ldr.h" +#include "ramfile.h" +#include "rawfile.h" +#include "shader.h" +#include "v3_rnd.h" +#include "vector2.h" +#include "vector3.h" +#include "w3d_file.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace { +constexpr float kFloatTolerance = 0.0001f; +constexpr std::uint32_t kUnknownLineFlag = 0x00010000u; + +bool fuzzyEqual(float actual, float expected) +{ + return std::fabs(actual - expected) <= kFloatTolerance; +} + +bool fuzzyEqual(const Vector2 &actual, const Vector2 &expected) +{ + return fuzzyEqual(actual.X, expected.X) && fuzzyEqual(actual.Y, expected.Y); +} + +bool fuzzyEqual(const Vector3 &actual, const Vector3 &expected) +{ + return fuzzyEqual(actual.X, expected.X) && fuzzyEqual(actual.Y, expected.Y) + && fuzzyEqual(actual.Z, expected.Z); +} + +void compareValue(float actual, float expected) +{ + QVERIFY(fuzzyEqual(actual, expected)); +} + +void compareValue(const Vector3 &actual, const Vector3 &expected) +{ + QVERIFY(fuzzyEqual(actual, expected)); +} + +template +struct OwnedProperty { + ParticlePropertyStruct value{}; + + ~OwnedProperty() + { + delete[] value.KeyTimes; + delete[] value.Values; + } + + OwnedProperty(const OwnedProperty &) = delete; + OwnedProperty &operator=(const OwnedProperty &) = delete; + OwnedProperty() = default; +}; + +template +using PropertyGetter = void (ParticleEmitterDefClass::*)(ParticlePropertyStruct &) const; + +template +void compareProperty(const ParticleEmitterDefClass &actual, + const ParticleEmitterDefClass &expected, + PropertyGetter getter) +{ + OwnedProperty actualProperty; + OwnedProperty expectedProperty; + (actual.*getter)(actualProperty.value); + (expected.*getter)(expectedProperty.value); + + compareValue(actualProperty.value.Start, expectedProperty.value.Start); + compareValue(actualProperty.value.Rand, expectedProperty.value.Rand); + QCOMPARE(actualProperty.value.NumKeyFrames, expectedProperty.value.NumKeyFrames); + for (unsigned int index = 0; index < actualProperty.value.NumKeyFrames; ++index) { + QVERIFY(fuzzyEqual(actualProperty.value.KeyTimes[index], expectedProperty.value.KeyTimes[index])); + compareValue(actualProperty.value.Values[index], expectedProperty.value.Values[index]); + } +} + +template +void comparePropertyWithScaledTimes(const ParticleEmitterDefClass &actual, + const ParticleEmitterDefClass &original, + PropertyGetter getter, + float scale) +{ + OwnedProperty actualProperty; + OwnedProperty originalProperty; + (actual.*getter)(actualProperty.value); + (original.*getter)(originalProperty.value); + + compareValue(actualProperty.value.Start, originalProperty.value.Start); + compareValue(actualProperty.value.Rand, originalProperty.value.Rand); + QCOMPARE(actualProperty.value.NumKeyFrames, originalProperty.value.NumKeyFrames); + for (unsigned int index = 0; index < actualProperty.value.NumKeyFrames; ++index) { + QVERIFY(fuzzyEqual(actualProperty.value.KeyTimes[index], + originalProperty.value.KeyTimes[index] * scale)); + compareValue(actualProperty.value.Values[index], originalProperty.value.Values[index]); + } +} + +void compareRandomizer(Vector3Randomizer *actualRaw, Vector3Randomizer *expectedRaw) +{ + std::unique_ptr actual(actualRaw); + std::unique_ptr expected(expectedRaw); + QCOMPARE(actual != nullptr, expected != nullptr); + if (!actual || !expected) { + return; + } + + QCOMPARE(actual->Class_ID(), expected->Class_ID()); + switch (actual->Class_ID()) { + case Vector3Randomizer::CLASSID_SOLIDBOX: + QVERIFY(fuzzyEqual(static_cast(actual.get())->Get_Extents(), + static_cast(expected.get())->Get_Extents())); + break; + case Vector3Randomizer::CLASSID_SOLIDSPHERE: + QVERIFY(fuzzyEqual(static_cast(actual.get())->Get_Radius(), + static_cast(expected.get())->Get_Radius())); + break; + case Vector3Randomizer::CLASSID_HOLLOWSPHERE: + QVERIFY(fuzzyEqual(static_cast(actual.get())->Get_Radius(), + static_cast(expected.get())->Get_Radius())); + break; + case Vector3Randomizer::CLASSID_SOLIDCYLINDER: + QVERIFY(fuzzyEqual(static_cast(actual.get())->Get_Height(), + static_cast(expected.get())->Get_Height())); + QVERIFY(fuzzyEqual(static_cast(actual.get())->Get_Radius(), + static_cast(expected.get())->Get_Radius())); + break; + default: + QFAIL("Unexpected randomizer class"); + } +} + +void compareDefinitions(const ParticleEmitterDefClass &actual, + const ParticleEmitterDefClass &expected) +{ + QCOMPARE(QByteArray(actual.Get_Name()), QByteArray(expected.Get_Name())); + QCOMPARE(QByteArray(actual.Get_Texture_Filename()), QByteArray(expected.Get_Texture_Filename())); + QVERIFY(fuzzyEqual(actual.Get_Lifetime(), expected.Get_Lifetime())); + QVERIFY(fuzzyEqual(actual.Get_Emission_Rate(), expected.Get_Emission_Rate())); + QVERIFY(fuzzyEqual(actual.Get_Max_Emissions(), expected.Get_Max_Emissions())); + QVERIFY(fuzzyEqual(actual.Get_Fade_Time(), expected.Get_Fade_Time())); + QVERIFY(fuzzyEqual(actual.Get_Gravity(), expected.Get_Gravity())); + QVERIFY(fuzzyEqual(actual.Get_Elasticity(), expected.Get_Elasticity())); + QVERIFY(fuzzyEqual(actual.Get_Velocity(), expected.Get_Velocity())); + QVERIFY(fuzzyEqual(actual.Get_Acceleration(), expected.Get_Acceleration())); + QCOMPARE(actual.Get_Burst_Size(), expected.Get_Burst_Size()); + QVERIFY(fuzzyEqual(actual.Get_Outward_Vel(), expected.Get_Outward_Vel())); + QVERIFY(fuzzyEqual(actual.Get_Vel_Inherit(), expected.Get_Vel_Inherit())); + QCOMPARE(actual.Get_Render_Mode(), expected.Get_Render_Mode()); + QCOMPARE(actual.Get_Frame_Mode(), expected.Get_Frame_Mode()); + + ShaderClass actualShader; + ShaderClass expectedShader; + actual.Get_Shader(actualShader); + expected.Get_Shader(expectedShader); + QCOMPARE(actualShader.Get_Bits(), expectedShader.Get_Bits()); + + compareRandomizer(actual.Get_Creation_Volume(), expected.Get_Creation_Volume()); + compareRandomizer(actual.Get_Velocity_Random(), expected.Get_Velocity_Random()); + compareProperty(actual, expected, &ParticleEmitterDefClass::Get_Color_Keyframes); + compareProperty(actual, expected, &ParticleEmitterDefClass::Get_Opacity_Keyframes); + compareProperty(actual, expected, &ParticleEmitterDefClass::Get_Size_Keyframes); + compareProperty(actual, expected, &ParticleEmitterDefClass::Get_Rotation_Keyframes); + compareProperty(actual, expected, &ParticleEmitterDefClass::Get_Frame_Keyframes); + compareProperty(actual, expected, &ParticleEmitterDefClass::Get_Blur_Time_Keyframes); + QVERIFY(fuzzyEqual(actual.Get_Initial_Orientation_Random(), + expected.Get_Initial_Orientation_Random())); + + QCOMPARE(QByteArray(actual.Get_User_String()), QByteArray(expected.Get_User_String())); + QCOMPARE(actual.Get_User_Type(), expected.Get_User_Type()); + + const W3dEmitterLinePropertiesStruct *actualLine = actual.Get_Line_Properties(); + const W3dEmitterLinePropertiesStruct *expectedLine = expected.Get_Line_Properties(); + QCOMPARE(actualLine->Flags, expectedLine->Flags); + QCOMPARE(actualLine->SubdivisionLevel, expectedLine->SubdivisionLevel); + QVERIFY(fuzzyEqual(actualLine->NoiseAmplitude, expectedLine->NoiseAmplitude)); + QVERIFY(fuzzyEqual(actualLine->MergeAbortFactor, expectedLine->MergeAbortFactor)); + QVERIFY(fuzzyEqual(actualLine->TextureTileFactor, expectedLine->TextureTileFactor)); + QVERIFY(fuzzyEqual(actual.Get_UV_Offset_Rate(), expected.Get_UV_Offset_Rate())); + for (int index = 0; index < 9; ++index) { + QCOMPARE(actualLine->Reserved[index], expectedLine->Reserved[index]); + } +} + +void setFixtureKeyframes(ParticleEmitterDefClass &definition) +{ + float times[] = {1.25f, 4.5f}; + + Vector3 colorValues[] = {Vector3(0.2f, 0.3f, 0.4f), Vector3(0.8f, 0.7f, 0.6f)}; + ParticlePropertyStruct color{Vector3(0.1f, 0.2f, 0.3f), + Vector3(0.01f, 0.02f, 0.03f), + 2, + times, + colorValues}; + definition.Set_Color_Keyframes(color); + + float opacityValues[] = {0.75f, 0.25f}; + ParticlePropertyStruct opacity{0.9f, 0.08f, 2, times, opacityValues}; + definition.Set_Opacity_Keyframes(opacity); + + float sizeValues[] = {1.5f, 3.5f}; + ParticlePropertyStruct size{0.5f, 0.2f, 2, times, sizeValues}; + definition.Set_Size_Keyframes(size); + + float rotationValues[] = {0.5f, -0.25f}; + ParticlePropertyStruct rotation{0.1f, 0.05f, 2, times, rotationValues}; + definition.Set_Rotation_Keyframes(rotation, 0.33f); + + float frameValues[] = {2.0f, 7.0f}; + ParticlePropertyStruct frame{1.0f, 0.5f, 2, times, frameValues}; + definition.Set_Frame_Keyframes(frame); + + float blurValues[] = {0.04f, 0.15f}; + ParticlePropertyStruct blur{0.02f, 0.01f, 2, times, blurValues}; + definition.Set_Blur_Time_Keyframes(blur); +} + +ParticleEmitterDefClass makeFixtureDefinition() +{ + ParticleEmitterDefClass definition; + definition.Set_Name("EmitterFixture"); + definition.Set_Texture_Filename("fixture.dds"); + definition.Set_User_String("fixture user data"); + definition.Set_User_Type(77); + definition.Set_Lifetime(10.0f); + definition.Set_Emission_Rate(12.5f); + definition.Set_Max_Emissions(321.0f); + definition.Set_Fade_Time(0.75f); + definition.Set_Gravity(-9.25f); + definition.Set_Elasticity(0.42f); + definition.Set_Velocity(Vector3(1.25f, -2.5f, 3.75f)); + definition.Set_Acceleration(Vector3(-0.5f, 0.25f, 0.75f)); + definition.Set_Burst_Size(7); + definition.Set_Outward_Vel(4.25f); + definition.Set_Vel_Inherit(0.35f); + definition.Set_Render_Mode(W3D_EMITTER_RENDER_MODE_LINE); + definition.Set_Frame_Mode(37); + + ShaderClass customShader = ShaderClass::_PresetAlphaSpriteShader; + // PASS_ALWAYS survives the W3dShaderStruct conversion but intentionally + // differs from every sprite preset offered by the dialog. + customShader.Set_Depth_Compare(ShaderClass::PASS_ALWAYS); + definition.Set_Shader(customShader); + + definition.Set_Creation_Volume(new Vector3SolidBoxRandomizer(Vector3(1.1f, 2.2f, 3.3f))); + definition.Set_Velocity_Random(new Vector3SolidCylinderRandomizer(4.4f, 5.5f)); + setFixtureKeyframes(definition); + + auto *line = const_cast(definition.Get_Line_Properties()); + line->Flags = kUnknownLineFlag | W3D_ELINE_MERGE_INTERSECTIONS | W3D_ELINE_FREEZE_RANDOM + | W3D_ELINE_DISABLE_SORTING | W3D_ELINE_END_CAPS + | (W3D_ELINE_TILED_TEXTURE_MAP << W3D_ELINE_TEXTURE_MAP_MODE_OFFSET); + line->SubdivisionLevel = 5; + line->NoiseAmplitude = 1.75f; + line->MergeAbortFactor = 0.45f; + line->TextureTileFactor = 2.25f; + line->UPerSec = -0.125f; + line->VPerSec = 0.875f; + for (int index = 0; index < 9; ++index) { + line->Reserved[index] = 0xA0000000u + static_cast(index); + } + + return definition; +} + +std::unique_ptr acceptDialog(EmitterEditDialog &dialog) +{ + auto *buttonBox = dialog.findChild("buttonBox"); + if (!buttonBox) { + return nullptr; + } + QPushButton *okButton = buttonBox->button(QDialogButtonBox::Ok); + if (!okButton) { + return nullptr; + } + okButton->click(); + QApplication::processEvents(); + if (dialog.result() != QDialog::Accepted) { + return nullptr; + } + return std::unique_ptr(dialog.definition()); +} + +void verifyRandomizer(Vector3Randomizer *raw, + int classId, + float first, + float second, + float third) +{ + std::unique_ptr randomizer(raw); + QVERIFY(randomizer); + QCOMPARE(static_cast(randomizer->Class_ID()), classId); + switch (randomizer->Class_ID()) { + case Vector3Randomizer::CLASSID_SOLIDBOX: { + const Vector3 extents = static_cast(randomizer.get())->Get_Extents(); + QVERIFY(fuzzyEqual(extents, Vector3(first, second, third))); + break; + } + case Vector3Randomizer::CLASSID_SOLIDSPHERE: + QVERIFY(fuzzyEqual(static_cast(randomizer.get())->Get_Radius(), first)); + break; + case Vector3Randomizer::CLASSID_HOLLOWSPHERE: + QVERIFY(fuzzyEqual(static_cast(randomizer.get())->Get_Radius(), first)); + break; + case Vector3Randomizer::CLASSID_SOLIDCYLINDER: + QVERIFY(fuzzyEqual(static_cast(randomizer.get())->Get_Height(), first)); + QVERIFY(fuzzyEqual(static_cast(randomizer.get())->Get_Radius(), second)); + break; + default: + QFAIL("Unexpected randomizer class"); + } +} +} // namespace + +class EmitterEditDialogTests final : public QObject +{ + Q_OBJECT + +private slots: + void noOpRoundTripPreservesAllObservableData(); + void unrelatedEditPreservesCustomShader(); + void componentEditDoesNotChangeSiblingFields(); + void randomizerEditsRoundTrip_data(); + void randomizerEditsRoundTrip(); + void lifetimeChangeRescalesEveryKeyframeChannel(); + void lineFlagEditPreservesUnknownBitsAndReservedData(); + void userStringEditPreservesWhitespace(); + void applyWithoutCloseAdvancesRegisteredName(); + void cancelAfterApplyPreservesLastAppliedDefinition(); + void okDoesNotRepeatCleanApply(); + void serializerRejectsTruncatedBlurChunk(); +}; + +void EmitterEditDialogTests::noOpRoundTripPreservesAllObservableData() +{ + const ParticleEmitterDefClass original = makeFixtureDefinition(); + EmitterEditDialog dialog(original); + + const std::unique_ptr result = acceptDialog(dialog); + QVERIFY(result); + compareDefinitions(*result, original); +} + +void EmitterEditDialogTests::unrelatedEditPreservesCustomShader() +{ + const ParticleEmitterDefClass original = makeFixtureDefinition(); + EmitterEditDialog dialog(original); + + auto *shaderCombo = dialog.findChild("shaderCombo"); + auto *gravitySpin = dialog.findChild("gravitySpin"); + QVERIFY(shaderCombo); + QVERIFY(gravitySpin); + QVERIFY(shaderCombo->currentText().startsWith("Custom")); + gravitySpin->setValue(-3.5); + + const std::unique_ptr result = acceptDialog(dialog); + QVERIFY(result); + ShaderClass originalShader; + ShaderClass resultShader; + original.Get_Shader(originalShader); + result->Get_Shader(resultShader); + QCOMPARE(resultShader.Get_Bits(), originalShader.Get_Bits()); + QVERIFY(fuzzyEqual(result->Get_Gravity(), -3.5f)); +} + +void EmitterEditDialogTests::componentEditDoesNotChangeSiblingFields() +{ + const ParticleEmitterDefClass original = makeFixtureDefinition(); + EmitterEditDialog dialog(original); + + auto *velocityXSpin = dialog.findChild("velocityXSpin"); + auto *colorStartRSpin = dialog.findChild("colorStartRSpin"); + QVERIFY(velocityXSpin); + QVERIFY(colorStartRSpin); + velocityXSpin->setValue(9.5); + colorStartRSpin->setValue(0.95); + + const std::unique_ptr result = acceptDialog(dialog); + QVERIFY(result); + const Vector3 velocity = result->Get_Velocity(); + const Vector3 originalVelocity = original.Get_Velocity(); + QVERIFY(fuzzyEqual(velocity.X, 9.5f)); + QVERIFY(fuzzyEqual(velocity.Y, originalVelocity.Y)); + QVERIFY(fuzzyEqual(velocity.Z, originalVelocity.Z)); + + OwnedProperty colors; + OwnedProperty originalColors; + result->Get_Color_Keyframes(colors.value); + original.Get_Color_Keyframes(originalColors.value); + QVERIFY(fuzzyEqual(colors.value.Start.X, 0.95f)); + QVERIFY(fuzzyEqual(colors.value.Start.Y, originalColors.value.Start.Y)); + QVERIFY(fuzzyEqual(colors.value.Start.Z, originalColors.value.Start.Z)); + QVERIFY(fuzzyEqual(colors.value.Rand, originalColors.value.Rand)); +} + +void EmitterEditDialogTests::randomizerEditsRoundTrip_data() +{ + QTest::addColumn("classId"); + QTest::addColumn("first"); + QTest::addColumn("second"); + QTest::addColumn("third"); + + QTest::newRow("solid-box") << static_cast(Vector3Randomizer::CLASSID_SOLIDBOX) + << 6.25f << 7.5f << 8.75f; + QTest::newRow("solid-sphere") << static_cast(Vector3Randomizer::CLASSID_SOLIDSPHERE) + << 9.25f << 0.0f << 0.0f; + QTest::newRow("hollow-sphere") << static_cast(Vector3Randomizer::CLASSID_HOLLOWSPHERE) + << 10.5f << 0.0f << 0.0f; + QTest::newRow("solid-cylinder") << static_cast(Vector3Randomizer::CLASSID_SOLIDCYLINDER) + << 11.75f << 12.5f << 0.0f; +} + +void EmitterEditDialogTests::randomizerEditsRoundTrip() +{ + QFETCH(int, classId); + QFETCH(float, first); + QFETCH(float, second); + QFETCH(float, third); + + const ParticleEmitterDefClass original = makeFixtureDefinition(); + EmitterEditDialog dialog(original); + + auto setRandomizerControls = [&dialog, classId, first, second, third](const char *comboName, + const char *firstName, + const char *secondName, + const char *thirdName) { + auto *combo = dialog.findChild(comboName); + auto *firstSpin = dialog.findChild(firstName); + auto *secondSpin = dialog.findChild(secondName); + auto *thirdSpin = dialog.findChild(thirdName); + if (!combo || !firstSpin || !secondSpin || !thirdSpin) { + return false; + } + const int index = combo->findData(classId); + if (index < 0) { + return false; + } + combo->setCurrentIndex(index); + firstSpin->setValue(first); + secondSpin->setValue(second); + thirdSpin->setValue(third); + return true; + }; + + QVERIFY(setRandomizerControls("creationTypeCombo", + "creationValue1Spin", + "creationValue2Spin", + "creationValue3Spin")); + QVERIFY(setRandomizerControls("velocityRandomTypeCombo", + "velocityRandomValue1Spin", + "velocityRandomValue2Spin", + "velocityRandomValue3Spin")); + + const std::unique_ptr result = acceptDialog(dialog); + QVERIFY(result); + verifyRandomizer(result->Get_Creation_Volume(), classId, first, second, third); + verifyRandomizer(result->Get_Velocity_Random(), classId, first, second, third); +} + +void EmitterEditDialogTests::lifetimeChangeRescalesEveryKeyframeChannel() +{ + const ParticleEmitterDefClass original = makeFixtureDefinition(); + EmitterEditDialog dialog(original); + + auto *useLifetimeCheck = dialog.findChild("useLifetimeCheck"); + auto *lifetimeSpin = dialog.findChild("lifetimeSpin"); + QVERIFY(useLifetimeCheck); + QVERIFY(lifetimeSpin); + QVERIFY(useLifetimeCheck->isChecked()); + lifetimeSpin->setValue(25.0); + + const std::unique_ptr result = acceptDialog(dialog); + QVERIFY(result); + QVERIFY(fuzzyEqual(result->Get_Lifetime(), 25.0f)); + constexpr float scale = 2.5f; + comparePropertyWithScaledTimes(*result, original, &ParticleEmitterDefClass::Get_Color_Keyframes, scale); + comparePropertyWithScaledTimes(*result, original, &ParticleEmitterDefClass::Get_Opacity_Keyframes, scale); + comparePropertyWithScaledTimes(*result, original, &ParticleEmitterDefClass::Get_Size_Keyframes, scale); + comparePropertyWithScaledTimes(*result, original, &ParticleEmitterDefClass::Get_Rotation_Keyframes, scale); + comparePropertyWithScaledTimes(*result, original, &ParticleEmitterDefClass::Get_Frame_Keyframes, scale); + comparePropertyWithScaledTimes(*result, original, &ParticleEmitterDefClass::Get_Blur_Time_Keyframes, scale); +} + +void EmitterEditDialogTests::lineFlagEditPreservesUnknownBitsAndReservedData() +{ + const ParticleEmitterDefClass original = makeFixtureDefinition(); + EmitterEditDialog dialog(original); + + auto *mergeCheck = dialog.findChild("lineMergeCheck"); + auto *mappingCombo = dialog.findChild("lineMappingCombo"); + QVERIFY(mergeCheck); + QVERIFY(mappingCombo); + QVERIFY(mergeCheck->isChecked()); + mergeCheck->setChecked(false); + const int mappingIndex = mappingCombo->findData(W3D_ELINE_UNIFORM_LENGTH_TEXTURE_MAP); + QVERIFY(mappingIndex >= 0); + mappingCombo->setCurrentIndex(mappingIndex); + + const std::unique_ptr result = acceptDialog(dialog); + QVERIFY(result); + const W3dEmitterLinePropertiesStruct *line = result->Get_Line_Properties(); + const W3dEmitterLinePropertiesStruct *originalLine = original.Get_Line_Properties(); + QVERIFY((line->Flags & kUnknownLineFlag) != 0); + QVERIFY((line->Flags & W3D_ELINE_MERGE_INTERSECTIONS) == 0); + QVERIFY((line->Flags & W3D_ELINE_FREEZE_RANDOM) != 0); + QVERIFY((line->Flags & W3D_ELINE_DISABLE_SORTING) != 0); + QVERIFY((line->Flags & W3D_ELINE_END_CAPS) != 0); + QCOMPARE(result->Get_Line_Texture_Mapping_Mode(), W3D_ELINE_UNIFORM_LENGTH_TEXTURE_MAP); + for (int index = 0; index < 9; ++index) { + QCOMPARE(line->Reserved[index], originalLine->Reserved[index]); + } +} + +void EmitterEditDialogTests::userStringEditPreservesWhitespace() +{ + const ParticleEmitterDefClass original = makeFixtureDefinition(); + EmitterEditDialog dialog(original); + + auto *userStringEdit = dialog.findChild("userStringEdit"); + QVERIFY(userStringEdit); + const QString exactText = QStringLiteral(" leading spaces\n\tmiddle\t \ntrailing spaces "); + userStringEdit->setPlainText(exactText); + + const std::unique_ptr result = acceptDialog(dialog); + QVERIFY(result); + QCOMPARE(QString::fromLatin1(result->Get_User_String()), exactText); +} + +void EmitterEditDialogTests::applyWithoutCloseAdvancesRegisteredName() +{ + const ParticleEmitterDefClass original = makeFixtureDefinition(); + EmitterEditDialog dialog(original); + + auto *buttonBox = dialog.findChild("buttonBox"); + auto *nameEdit = dialog.findChild("nameEdit"); + QVERIFY(buttonBox); + QVERIFY(nameEdit); + QPushButton *applyButton = buttonBox->button(QDialogButtonBox::Apply); + QVERIFY(applyButton); + + QStringList registeredNames; + QStringList appliedNames; + dialog.setApplyHandler( + [®isteredNames, &appliedNames](const ParticleEmitterDefClass &definition, + const QString ®isteredName) { + registeredNames.push_back(registeredName); + appliedNames.push_back(QString::fromLatin1(definition.Get_Name())); + return true; + }, + dialog.originalName()); + + dialog.show(); + nameEdit->selectAll(); + QTest::keyClicks(nameEdit, "FirstAppliedName"); + applyButton->click(); + QApplication::processEvents(); + QVERIFY(dialog.isVisible()); + QCOMPARE(registeredNames, QStringList({"EmitterFixture"})); + QCOMPARE(appliedNames, QStringList({"FirstAppliedName"})); + + nameEdit->selectAll(); + QTest::keyClicks(nameEdit, "SecondAppliedName"); + applyButton->click(); + QApplication::processEvents(); + QVERIFY(dialog.isVisible()); + QCOMPARE(registeredNames, QStringList({"EmitterFixture", "FirstAppliedName"})); + QCOMPARE(appliedNames, QStringList({"FirstAppliedName", "SecondAppliedName"})); +} + +void EmitterEditDialogTests::cancelAfterApplyPreservesLastAppliedDefinition() +{ + const ParticleEmitterDefClass original = makeFixtureDefinition(); + EmitterEditDialog dialog(original); + + auto *buttonBox = dialog.findChild("buttonBox"); + auto *nameEdit = dialog.findChild("nameEdit"); + auto *gravitySpin = dialog.findChild("gravitySpin"); + QVERIFY(buttonBox); + QVERIFY(nameEdit); + QVERIFY(gravitySpin); + QPushButton *applyButton = buttonBox->button(QDialogButtonBox::Apply); + QPushButton *cancelButton = buttonBox->button(QDialogButtonBox::Cancel); + QVERIFY(applyButton); + QVERIFY(cancelButton); + + QString appliedName; + float appliedGravity = 0.0f; + int applyCount = 0; + dialog.setApplyHandler( + [&appliedName, &appliedGravity, &applyCount](const ParticleEmitterDefClass &definition, + const QString &) { + ++applyCount; + appliedName = QString::fromLatin1(definition.Get_Name()); + appliedGravity = definition.Get_Gravity(); + return true; + }, + dialog.originalName()); + + dialog.show(); + QApplication::processEvents(); + nameEdit->selectAll(); + QTest::keyClicks(nameEdit, "KeptAppliedName"); + gravitySpin->setValue(-4.5); + applyButton->click(); + QApplication::processEvents(); + QCOMPARE(applyCount, 1); + + nameEdit->selectAll(); + QTest::keyClicks(nameEdit, "CancelledName"); + gravitySpin->setValue(-9.0); + cancelButton->click(); + QApplication::processEvents(); + QCOMPARE(dialog.result(), static_cast(QDialog::Rejected)); + QCOMPARE(applyCount, 1); + QCOMPARE(appliedName, QString("KeptAppliedName")); + QVERIFY(fuzzyEqual(appliedGravity, -4.5f)); + + const std::unique_ptr lastApplied(dialog.definition()); + QVERIFY(lastApplied); + QCOMPARE(QString::fromLatin1(lastApplied->Get_Name()), QString("KeptAppliedName")); + QVERIFY(fuzzyEqual(lastApplied->Get_Gravity(), -4.5f)); +} + +void EmitterEditDialogTests::okDoesNotRepeatCleanApply() +{ + const ParticleEmitterDefClass original = makeFixtureDefinition(); + EmitterEditDialog dialog(original); + + auto *buttonBox = dialog.findChild("buttonBox"); + auto *gravitySpin = dialog.findChild("gravitySpin"); + QVERIFY(buttonBox); + QVERIFY(gravitySpin); + QPushButton *applyButton = buttonBox->button(QDialogButtonBox::Apply); + QPushButton *okButton = buttonBox->button(QDialogButtonBox::Ok); + QVERIFY(applyButton); + QVERIFY(okButton); + + int applyCount = 0; + dialog.setApplyHandler( + [&applyCount](const ParticleEmitterDefClass &, const QString &) { + ++applyCount; + return true; + }, + dialog.originalName()); + + gravitySpin->setValue(-7.25); + applyButton->click(); + QApplication::processEvents(); + QCOMPARE(applyCount, 1); + QVERIFY(!applyButton->isEnabled()); + + okButton->click(); + QApplication::processEvents(); + QCOMPARE(dialog.result(), static_cast(QDialog::Accepted)); + QCOMPARE(applyCount, 1); +} + +void EmitterEditDialogTests::serializerRejectsTruncatedBlurChunk() +{ + const QString assetDirectory = qEnvironmentVariable("W3DVIEW_EXTERNAL_ASSET_DIR"); + if (assetDirectory.isEmpty()) { + QSKIP("Set W3DVIEW_EXTERNAL_ASSET_DIR to run the real-emitter serializer regression"); + } + + const QString sourcePath = QDir(assetDirectory).filePath("e_flare02.w3d"); + QVERIFY2(QFileInfo::exists(sourcePath), + qPrintable(QString("Missing integration asset: %1").arg(sourcePath))); + + ParticleEmitterDefClass definition; + { + const QByteArray nativePath = QDir::toNativeSeparators(sourcePath).toLocal8Bit(); + RawFileClass source(nativePath.constData()); + QVERIFY(source.Open(FileClass::READ)); + + ChunkLoadClass load(&source); + QVERIFY(load.Open_Chunk()); + QCOMPARE(load.Cur_Chunk_ID(), static_cast(W3D_CHUNK_EMITTER)); + QCOMPARE(definition.Load_W3D(load), WW3D_ERROR_OK); + QVERIFY(load.Close_Chunk()); + QCOMPARE(source.Tell(), source.Size()); + source.Close(); + } + + // e_flare02 normally serializes to 708 bytes. At 688 bytes the final blur + // chunk header fits, but its mandatory header and start keyframe do not. + std::array storage = {}; + RAMFileClass destination(storage.data(), static_cast(storage.size())); + QVERIFY(destination.Open(FileClass::WRITE)); + + ChunkSaveClass save(&destination); + QCOMPARE(definition.Save_W3D(save), WW3D_ERROR_SAVE_FAILED); + QVERIFY(save.Has_Write_Error()); + QCOMPARE(save.Cur_Chunk_Depth(), 0); + QCOMPARE(destination.Size(), static_cast(storage.size())); + destination.Close(); + + // The failed write still leaves a balanced zero-length blur chunk. This + // prevents structural validity from masking the serializer failure again. + QVERIFY(destination.Open(FileClass::READ)); + ChunkLoadClass truncatedLoad(&destination); + QVERIFY(truncatedLoad.Open_Chunk()); + QCOMPARE(truncatedLoad.Cur_Chunk_ID(), static_cast(W3D_CHUNK_EMITTER)); + QCOMPARE(truncatedLoad.Cur_Chunk_Length() + sizeof(ChunkHeader), storage.size()); + + uint32 lastChildId = 0; + uint32 lastChildLength = 0; + while (truncatedLoad.Open_Chunk()) { + lastChildId = truncatedLoad.Cur_Chunk_ID(); + lastChildLength = truncatedLoad.Cur_Chunk_Length(); + QVERIFY(truncatedLoad.Close_Chunk()); + } + QCOMPARE(lastChildId, static_cast(W3D_CHUNK_EMITTER_BLUR_TIME_KEYFRAMES)); + QCOMPARE(lastChildLength, static_cast(0)); + QVERIFY(truncatedLoad.Close_Chunk()); + QCOMPARE(destination.Tell(), destination.Size()); + destination.Close(); +} + +int main(int argc, char **argv) +{ + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) { + qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen")); + } + + QApplication application(argc, argv); + EmitterEditDialogTests tests; + return QTest::qExec(&tests, argc, argv); +} + +#include "EmitterEditDialogTests.moc" diff --git a/Code/Tools/W3DViewQt/tests/ExportDirectoryDialogTests.cpp b/Code/Tools/W3DViewQt/tests/ExportDirectoryDialogTests.cpp new file mode 100644 index 000000000..97605017c --- /dev/null +++ b/Code/Tools/W3DViewQt/tests/ExportDirectoryDialogTests.cpp @@ -0,0 +1,130 @@ +#include "ExportDirectoryDialog.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +QLineEdit *lineEdit(ExportDirectoryDialog &dialog, const char *name) +{ + QLineEdit *edit = dialog.findChild(name); + if (!edit) { + QTest::qFail("Expected line edit was not created from the Designer form", + __FILE__, + __LINE__); + } + return edit; +} + +QPushButton *dialogButton(ExportDirectoryDialog &dialog, + QDialogButtonBox::StandardButton standardButton) +{ + QDialogButtonBox *buttonBox = dialog.findChild("buttonBox"); + if (!buttonBox) { + QTest::qFail("buttonBox was not created from the Designer form", __FILE__, __LINE__); + return nullptr; + } + + QPushButton *button = buttonBox->button(standardButton); + if (!button) { + QTest::qFail("Expected standard dialog button was not created", __FILE__, __LINE__); + } + return button; +} +} + +class ExportDirectoryDialogTests final : public QObject +{ + Q_OBJECT + +private slots: + void filenameIsExactAndReadOnly(); + void selectedPathJoinsDirectoryAndExactFilename(); + void invalidDirectoryDisablesOkAndBlocksAcceptance(); + void cancelButtonRejectsDialog(); +}; + +void ExportDirectoryDialogTests::filenameIsExactAndReadOnly() +{ + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + + const QString exactFilename = QStringLiteral("XG_IonC_Shock0.w3d"); + ExportDirectoryDialog dialog(exactFilename, temporaryDirectory.path()); + + QLineEdit *filenameEdit = lineEdit(dialog, "filenameEdit"); + QVERIFY(filenameEdit); + QCOMPARE(filenameEdit->text(), exactFilename); + QVERIFY(filenameEdit->isReadOnly()); + + QLineEdit *directoryEdit = lineEdit(dialog, "directoryEdit"); + QVERIFY(directoryEdit); + QVERIFY(!directoryEdit->isReadOnly()); +} + +void ExportDirectoryDialogTests::selectedPathJoinsDirectoryAndExactFilename() +{ + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + + const QString exactFilename = QStringLiteral("C_NOD_SK_.w3d"); + ExportDirectoryDialog dialog(exactFilename, temporaryDirectory.path()); + QVERIFY(QDir(temporaryDirectory.path()).mkdir("chosen-directory")); + const QString chosenDirectory = + QDir(temporaryDirectory.path()).filePath("chosen-directory"); + QLineEdit *directoryEdit = lineEdit(dialog, "directoryEdit"); + QVERIFY(directoryEdit); + directoryEdit->setText(chosenDirectory); + + QCOMPARE(dialog.selectedPath(), + QDir(chosenDirectory).filePath(exactFilename)); +} + +void ExportDirectoryDialogTests::invalidDirectoryDisablesOkAndBlocksAcceptance() +{ + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + + ExportDirectoryDialog dialog(QStringLiteral("asset.w3d"), temporaryDirectory.path()); + QPushButton *okButton = dialogButton(dialog, QDialogButtonBox::Ok); + QVERIFY(okButton); + QVERIFY(okButton->isEnabled()); + + QLineEdit *directoryEdit = lineEdit(dialog, "directoryEdit"); + QVERIFY(directoryEdit); + directoryEdit->setText(QDir(temporaryDirectory.path()).filePath("missing-directory")); + QVERIFY(!okButton->isEnabled()); + + QSignalSpy acceptedSpy(&dialog, &QDialog::accepted); + dialog.accept(); + QCOMPARE(acceptedSpy.count(), 0); + QCOMPARE(dialog.result(), static_cast(QDialog::Rejected)); + + directoryEdit->setText(temporaryDirectory.path()); + QVERIFY(okButton->isEnabled()); +} + +void ExportDirectoryDialogTests::cancelButtonRejectsDialog() +{ + QTemporaryDir temporaryDirectory; + QVERIFY(temporaryDirectory.isValid()); + + ExportDirectoryDialog dialog(QStringLiteral("asset.w3d"), temporaryDirectory.path()); + QSignalSpy rejectedSpy(&dialog, &QDialog::rejected); + QPushButton *cancelButton = dialogButton(dialog, QDialogButtonBox::Cancel); + QVERIFY(cancelButton); + + cancelButton->click(); + + QCOMPARE(rejectedSpy.count(), 1); + QCOMPARE(dialog.result(), static_cast(QDialog::Rejected)); +} + +QTEST_MAIN(ExportDirectoryDialogTests) + +#include "ExportDirectoryDialogTests.moc" diff --git a/Code/Tools/W3DViewQt/tests/MainWindowCommandTests.cpp b/Code/Tools/W3DViewQt/tests/MainWindowCommandTests.cpp new file mode 100644 index 000000000..f76c62874 --- /dev/null +++ b/Code/Tools/W3DViewQt/tests/MainWindowCommandTests.cpp @@ -0,0 +1,2348 @@ +#include "MainWindow.h" +#include "AdvancedAnimationDialog.h" +#include "AnimationPropertiesDialog.h" +#include "AnimationSettingsDialog.h" +#include "ExportDirectoryDialog.h" +#include "RenderObjUtils.h" +#include "SoundEditDialog.h" +#include "SaveSettingsDialog.h" +#include "W3DExportUtils.h" +#include "W3DViewport.h" +#include "Sound3D.h" +#include "WWAudio.h" +#include "agg_def.h" +#include "assetmgr.h" +#include "chunkio.h" +#include "hanim.h" +#include "hlod.h" +#include "htree.h" +#include "part_ldr.h" +#include "ramfile.h" +#include "rawfile.h" +#include "rendobj.h" +#include "ringobj.h" +#include "soundrobj.h" +#include "sphereobj.h" +#include "vector3.h" +#include "w3d_file.h" +#include "wwmath.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { +QStringList toStringList(std::initializer_list values) +{ + QStringList result; + result.reserve(static_cast(values.size())); + for (const char *value : values) { + result.append(QString::fromLatin1(value)); + } + return result; +} + +QStringList commandIds(const QList &actions) +{ + QStringList result; + result.reserve(actions.size()); + for (QAction *action : actions) { + if (action->isSeparator()) { + result.append(""); + } else if (QMenu *menu = action->menu()) { + result.append("menu:" + menu->objectName()); + } else { + result.append(action->objectName()); + } + } + return result; +} + +QStringList portableShortcuts(const QAction &action) +{ + QStringList result; + for (const QKeySequence &shortcut : action.shortcuts()) { + result.append(shortcut.toString(QKeySequence::PortableText)); + } + return result; +} + +template +void setW3dName(char (&destination)[Size], const char *source) +{ + static_assert(Size > 0); + std::memset(destination, 0, Size); + if (!source) { + return; + } + + size_t length = std::strlen(source); + if (length >= Size) { + length = Size - 1; + } + std::memcpy(destination, source, length); +} + +bool writeDataChunk(ChunkSaveClass &save, + uint32 chunkId, + const void *data, + size_t size) +{ + if (!save.Begin_Chunk(chunkId)) { + return false; + } + + const bool wroteData = save.Write(data, size) == size; + const bool endedChunk = save.End_Chunk(); + return wroteData && endedChunk; +} + +template +bool writeDataChunk(ChunkSaveClass &save, uint32 chunkId, const Struct &data) +{ + return writeDataChunk(save, chunkId, &data, sizeof(data)); +} + +bool writeHierarchy(ChunkSaveClass &save, const char *name) +{ + W3dHierarchyStruct header = {}; + header.Version = W3D_CURRENT_HTREE_VERSION; + setW3dName(header.Name, name); + header.NumPivots = 1; + + W3dPivotStruct pivot = {}; + setW3dName(pivot.Name, "ROOTTRANSFORM"); + pivot.ParentIdx = static_cast(-1); + pivot.Rotation.Q[3] = 1.0f; + + if (!save.Begin_Chunk(W3D_CHUNK_HIERARCHY)) { + return false; + } + + const bool wroteHeader = writeDataChunk(save, W3D_CHUNK_HIERARCHY_HEADER, header); + const bool wrotePivots = wroteHeader && writeDataChunk(save, W3D_CHUNK_PIVOTS, pivot); + const bool endedHierarchy = save.End_Chunk(); + return wroteHeader && wrotePivots && endedHierarchy; +} + +bool writeHierarchyModel(ChunkSaveClass &save, + const char *modelName, + const char *hierarchyName) +{ + W3dHModelHeaderStruct header = {}; + header.Version = W3D_CURRENT_HMODEL_VERSION; + setW3dName(header.Name, modelName); + setW3dName(header.HierarchyName, hierarchyName); + header.NumConnections = 0; + + if (!save.Begin_Chunk(W3D_CHUNK_HMODEL)) { + return false; + } + + const bool wroteHeader = writeDataChunk(save, W3D_CHUNK_HMODEL_HEADER, header); + const bool endedModel = save.End_Chunk(); + return wroteHeader && endedModel; +} + +bool writeRawAnimation(ChunkSaveClass &save, + const char *animationName, + const char *hierarchyName) +{ + W3dAnimHeaderStruct header = {}; + header.Version = W3D_CURRENT_HANIM_VERSION; + setW3dName(header.Name, animationName); + setW3dName(header.HierarchyName, hierarchyName); + header.NumFrames = 2; + header.FrameRate = 30; + + constexpr size_t channelSize = + offsetof(W3dAnimChannelStruct, Data) + (2 * sizeof(float32)); + static_assert(sizeof(W3dAnimChannelStruct) <= channelSize); + std::array channelBytes = {}; + + W3dAnimChannelStruct channel = {}; + channel.FirstFrame = 0; + channel.LastFrame = 1; + channel.VectorLen = 1; + channel.Flags = ANIM_CHANNEL_X; + channel.Pivot = 0; + channel.Data[0] = 0.0f; + std::memcpy(channelBytes.data(), &channel, sizeof(channel)); + + const float32 secondFrame = 1.25f; + std::memcpy(channelBytes.data() + offsetof(W3dAnimChannelStruct, Data) + sizeof(float32), + &secondFrame, + sizeof(secondFrame)); + + if (!save.Begin_Chunk(W3D_CHUNK_ANIMATION)) { + return false; + } + + const bool wroteHeader = writeDataChunk(save, W3D_CHUNK_ANIMATION_HEADER, header); + const bool wroteChannel = wroteHeader && + writeDataChunk(save, + W3D_CHUNK_ANIMATION_CHANNEL, + channelBytes.data(), + channelBytes.size()); + const bool endedAnimation = save.End_Chunk(); + return wroteHeader && wroteChannel && endedAnimation; +} + +bool writeGeneratedAnimationFixture(const QString &path) +{ + const QByteArray nativePath = QDir::toNativeSeparators(path).toLocal8Bit(); + RawFileClass file(nativePath.constData()); + if (!file.Open(FileClass::WRITE)) { + return false; + } + + ChunkSaveClass save(&file); + const bool result = writeHierarchy(save, "TEST_RIG") && + writeHierarchy(save, "OTHER_RIG") && + writeHierarchyModel(save, "TEST_MODEL", "TEST_RIG") && + writeHierarchyModel(save, "OTHER_MODEL", "OTHER_RIG") && + writeRawAnimation(save, "TEST_MOVE", "TEST_RIG"); + file.Close(); + return result; +} + +QStringList collectAssetNames(AssetIterator *iterator) +{ + std::unique_ptr ownedIterator(iterator); + QStringList names; + if (!ownedIterator) { + return names; + } + + for (ownedIterator->First(); !ownedIterator->Is_Done(); ownedIterator->Next()) { + const char *name = ownedIterator->Current_Item_Name(); + if (name && name[0]) { + names.append(QString::fromLatin1(name)); + } + } + return names; +} + +QModelIndex findDirectChild(const QAbstractItemModel *model, + const QModelIndex &parent, + const QString &text) +{ + if (!model || !parent.isValid()) { + return {}; + } + + for (int row = 0; row < model->rowCount(parent); ++row) { + const QModelIndex candidate = model->index(row, 0, parent); + if (candidate.data().toString() == text) { + return candidate; + } + } + return {}; +} + +template +struct ReleaseRef +{ + void operator()(Type *value) const + { + if (value) { + value->Release_Ref(); + } + } +}; + +class CurrentDirectoryRestorer final +{ +public: + CurrentDirectoryRestorer() + : _path(QDir::currentPath()) + { + } + + ~CurrentDirectoryRestorer() + { + if (!_path.isEmpty()) { + QDir::setCurrent(_path); + } + } + +private: + QString _path; +}; + +class OneShotShortWriteRAMFile final : public RAMFileClass +{ +public: + OneShotShortWriteRAMFile(void *buffer, int length) + : RAMFileClass(buffer, length) + { + } + + int Write(const void *buffer, int size) override + { + // Chunk headers and microchunk headers have different sizes. The first + // four-byte write is the first sound-definition variable payload. + if (!_failed && size == static_cast(sizeof(float))) { + _failed = true; + return RAMFileClass::Write(buffer, size - 1); + } + return RAMFileClass::Write(buffer, size); + } + + bool failed() const { return _failed; } + +private: + bool _failed = false; +}; +} // namespace + +class MainWindowCommandTests final : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase(); + void designerHierarchyAndTopLevelMenus(); + void staticMenuCommandOrder(); + void toolbarCommandOrder(); + void dynamicMenusAndActionGroups(); + void shortcutParity(); + void emptyStartupCommandState(); + void selectionSpecificMenusAndEmptyPlaceholders(); + void recentFilesMatchMfcPresentationAndLimit(); + void settingsFilesAreValidatedBeforeApply(); + void relativeSettingsPathResolvesBesideExecutable(); + void emptyTexturePathsRemainEmpty(); + void startupManualClipDefaultsMatchMfc(); + void safeActionWiring(); + void restoredToolbarStateStaysSynchronized(); + void aggregateSubobjectNamesAreBounded(); + void soundPrototypeRegistrationRejectsCollisions(); + void soundSerializerReportsOneShotWriteFailure(); + void generatedHierarchyAnimationFixture(); + void externalAnimationAssetBundle(); + void externalRealAssetBundle(); + +private: + QAction *action(const char *objectName) const; + void compareMenu(const char *objectName, + std::initializer_list expected) const; + void compareToolbar(const char *objectName, + std::initializer_list expected) const; + QModelIndex findRootItem(const QString &prefix) const; + + std::unique_ptr _settingsDirectory; + std::unique_ptr _window; +}; + +void MainWindowCommandTests::initTestCase() +{ + _settingsDirectory = std::make_unique(); + QVERIFY2(_settingsDirectory->isValid(), "Could not create an isolated settings directory"); + + QSettings::setDefaultFormat(QSettings::IniFormat); + QSettings::setPath(QSettings::IniFormat, QSettings::UserScope, _settingsDirectory->path()); + QCoreApplication::setOrganizationName("OpenW3DTests"); + QCoreApplication::setApplicationName("W3DViewQtMainWindowCommandTests"); + + _window = std::make_unique(); + QVERIFY(_window); + QVERIFY2(!_window->isVisible(), "The offscreen command test must never show the main window"); + + for (QTimer *timer : _window->findChildren()) { + timer->stop(); + } +} + +QAction *MainWindowCommandTests::action(const char *objectName) const +{ + QAction *result = _window->findChild(objectName); + if (!result) { + QTest::qFail(qPrintable(QString("Missing action: %1").arg(objectName)), __FILE__, __LINE__); + } + return result; +} + +void MainWindowCommandTests::compareMenu( + const char *objectName, std::initializer_list expected) const +{ + QMenu *menu = _window->findChild(objectName); + QVERIFY2(menu, objectName); + const QStringList actual = commandIds(menu->actions()); + const QStringList expectedCommands = toStringList(expected); + QVERIFY2(actual == expectedCommands, + qPrintable(QString("%1 command order mismatch\nActual: %2\nExpected: %3") + .arg(objectName, + actual.join(", "), + expectedCommands.join(", ")))); +} + +void MainWindowCommandTests::compareToolbar( + const char *objectName, std::initializer_list expected) const +{ + QToolBar *toolbar = _window->findChild(objectName); + QVERIFY2(toolbar, objectName); + const QStringList actual = commandIds(toolbar->actions()); + const QStringList expectedCommands = toStringList(expected); + QVERIFY2(actual == expectedCommands, + qPrintable(QString("%1 command order mismatch\nActual: %2\nExpected: %3") + .arg(objectName, + actual.join(", "), + expectedCommands.join(", ")))); +} + +QModelIndex MainWindowCommandTests::findRootItem(const QString &prefix) const +{ + QTreeView *tree = _window->findChild("assetTreeView"); + if (!tree || !tree->model()) { + return {}; + } + for (int row = 0; row < tree->model()->rowCount(); ++row) { + const QModelIndex index = tree->model()->index(row, 0); + if (index.data().toString().startsWith(prefix)) { + return index; + } + } + return {}; +} + +void MainWindowCommandTests::designerHierarchyAndTopLevelMenus() +{ + const char *requiredObjects[] = { + "centralWidget", + "mainSplitter", + "assetTreeView", + "viewport", + "menuBar", + "statusBar", + "permanentStatusPanel", + "statusPolysLabel", + "statusParticlesLabel", + "statusCameraLabel", + "statusFramesLabel", + "statusFpsLabel", + "statusResolutionLabel", + "MainToolbar", + "ObjectToolbar", + "AnimationToolbar", + }; + for (const char *objectName : requiredObjects) { + QVERIFY2(_window->findChild(objectName), objectName); + } + + QCOMPARE(_window->objectName(), QString("W3DViewMainWindow")); + W3DViewport *viewport = _window->findChild("viewport"); + QVERIFY(viewport); + QVERIFY2(!viewport->isVisible(), "The native Direct3D viewport unexpectedly became visible"); + + QMenuBar *menuBar = _window->findChild("menuBar"); + QVERIFY(menuBar); + const QStringList actualMenus = commandIds(menuBar->actions()); + const QStringList expectedMenus = toStringList({"menu:fileMenu", + "menu:settingsMenu", + "menu:viewMenu", + "menu:objectMenu", + "menu:emittersMenu", + "menu:primitivesMenu", + "menu:soundMenu", + "menu:lightingMenu", + "menu:cameraMenu", + "menu:backgroundMenu", + "menu:movieMenu", + "menu:helpMenu"}); + QVERIFY(actualMenus == expectedMenus); + + QStringList titles; + for (QAction *menuAction : menuBar->actions()) { + QVERIFY(menuAction->menu()); + titles.append(menuAction->menu()->title()); + } + QVERIFY(titles == toStringList({"&File", + "&Settings", + "&View", + "&Object", + "&Emitters", + "&Primitives", + "&Sound", + "Ligh&ting", + "&Camera", + "&Background", + "&Movie", + "&Help"})); +} + +void MainWindowCommandTests::staticMenuCommandOrder() +{ + compareMenu("fileMenu", + {"actionNew", + "actionOpen", + "actionMungeSortOnLoad", + "actionEnableGammaCorrection", + "", + "actionSaveSettings", + "actionLoadSettings", + "", + "actionImportFacialAnims", + "menu:exportMenu", + "", + "actionFileTexturePath", + "actionAnimatedSoundOptions", + "", + "actionRecentFilesPlaceholder", + "", + "actionExit"}); + compareMenu("exportMenu", + {"actionExportAggregate", + "actionExportEmitter", + "actionExportLod", + "actionExportPrimitive", + "actionExportSoundObject"}); + compareMenu("settingsMenu", {"actionTexturePaths", "actionAutoExpandAssetTree"}); + compareMenu("viewMenu", + {"menu:toolbarsMenu", + "actionStatusBar", + "", + "actionSlideshowPrev", + "actionSlideshowNext", + "", + "actionChangeResolution", + "", + "actionWireframe", + "actionSorting", + "actionInvertBackfaceCulling", + "actionGamma", + "", + "menu:npatchesMenu", + "actionNpatchesGap"}); + compareMenu("toolbarsMenu", + {"actionToolbarMain", "actionToolbarObject", "actionToolbarAnimation"}); + compareMenu("objectMenu", + {"actionObjectRotateX", + "actionObjectRotateY", + "actionObjectRotateZ", + "", + "actionObjectProperties", + "", + "actionRestrictAnims", + "", + "actionObjectReset", + "", + "actionObjectAlternateMaterials"}); + compareMenu("emittersMenu", + {"actionCreateEmitter", + "actionScaleEmitter", + "", + "actionEditEmitter", + "menu:emittersEditMenu"}); + compareMenu("primitivesMenu", + {"actionCreateSphere", + "actionCreateRing", + "", + "actionEditPrimitive"}); + compareMenu("soundMenu", + {"actionCreateSoundObject", "", "actionEditSoundObject"}); + compareMenu("lightingMenu", + {"actionLightRotateY", + "actionLightRotateZ", + "", + "actionAmbientLight", + "actionSceneLight", + "", + "actionIncreaseAmbientLight", + "actionDecreaseAmbientLight", + "actionIncreaseSceneLight", + "actionDecreaseSceneLight", + "", + "actionExposePrelit", + "actionKillSceneLight", + "", + "actionPrelitVertex", + "actionPrelitMultipass", + "actionPrelitMultitex"}); + compareMenu("cameraMenu", + {"actionCameraFront", + "actionCameraBack", + "actionCameraLeft", + "actionCameraRight", + "actionCameraTop", + "actionCameraBottom", + "", + "actionCameraRotateX", + "actionCameraRotateY", + "actionCameraRotateZ", + "actionCameraCopyScreen", + "", + "actionCameraAnimate", + "actionCameraBonePosX", + "", + "actionCameraSettings", + "actionCameraDistance", + "", + "actionCameraResetOnDisplay", + "actionCameraReset"}); + compareMenu("backgroundMenu", + {"actionBackgroundColor", + "actionBackgroundBitmap", + "actionBackgroundObject", + "", + "actionFog"}); + compareMenu("movieMenu", {"actionMakeMovie", "actionCaptureScreenshot"}); + compareMenu("helpMenu", {"actionAbout"}); +} + +void MainWindowCommandTests::toolbarCommandOrder() +{ + compareToolbar("MainToolbar", + {"actionNew", + "actionOpen", + "", + "actionExportEmitter", + "actionExportAggregate", + "actionExportLod", + "actionExportPrimitive", + "actionExportSoundObject", + "", + "actionListMissingTextures", + "", + "actionCopyAssets", + "", + "actionAddToLineup", + "", + "actionAbout"}); + compareToolbar("ObjectToolbar", + {"actionCameraRotateY", + "actionCameraRotateX", + "actionCameraRotateZ", + "actionObjectRotateZ"}); + compareToolbar("AnimationToolbar", + {"actionToolbarAnimationPlay", + "actionToolbarAnimationStop", + "actionToolbarAnimationPause", + "actionToolbarAnimationStepBack", + "actionToolbarAnimationStepForward"}); +} + +void MainWindowCommandTests::dynamicMenusAndActionGroups() +{ + compareMenu("animationMenu", + {"actionToolbarAnimationPlay", + "actionToolbarAnimationPause", + "actionToolbarAnimationStop", + "", + "actionToolbarAnimationStepBack", + "actionToolbarAnimationStepForward", + "", + "actionAnimationSettings", + "", + "actionAnimationAdvanced"}); + compareMenu("hierarchyMenu", + {"actionHierarchyGenerateLod", "actionHierarchyMakeAggregate"}); + compareMenu("aggregateMenu", + {"actionAggregateRename", + "", + "actionAggregateBoneManagement", + "actionAggregateAutoAssignBones", + "", + "actionAggregateBindSubobjectLod", + "actionAggregateGenerateLod"}); + compareMenu("lodMenu", + {"actionLodRecordScreenArea", + "actionLodIncludeNull", + "", + "actionLodPrevious", + "actionLodNext", + "actionLodAutoSwitch", + "", + "actionLodMakeAggregate"}); + compareMenu("npatchesMenu", + {"actionNpatchesLevel1", + "actionNpatchesLevel2", + "actionNpatchesLevel3", + "actionNpatchesLevel4", + "actionNpatchesLevel5", + "actionNpatchesLevel6", + "actionNpatchesLevel7", + "actionNpatchesLevel8"}); + + QActionGroup *npatchesGroup = _window->findChild("npatchesGroup"); + QVERIFY(npatchesGroup); + QVERIFY(npatchesGroup->isExclusive()); + QCOMPARE(npatchesGroup->actions().size(), 8); + QCOMPARE(npatchesGroup->checkedAction(), action("actionNpatchesLevel4")); + + QActionGroup *prelitGroup = _window->findChild("prelitGroup"); + QVERIFY(prelitGroup); + QVERIFY(prelitGroup->isExclusive()); + QCOMPARE(prelitGroup->actions().size(), 3); + + QVERIFY(action("actionAggregateBindSubobjectLod")->isCheckable()); + QVERIFY(action("actionLodIncludeNull")->isCheckable()); + QVERIFY(action("actionLodAutoSwitch")->isCheckable()); +} + +void MainWindowCommandTests::shortcutParity() +{ + QMap expected; + auto expect = [&expected](const char *name, std::initializer_list shortcuts) { + expected.insert(QString::fromLatin1(name), toStringList(shortcuts)); + }; + + expect("actionNew", {"Ctrl+N"}); + expect("actionOpen", {"Ctrl+O"}); + expect("actionSaveSettings", {"Ctrl+S"}); + expect("actionSlideshowPrev", {"PgUp"}); + expect("actionSlideshowNext", {"PgDown"}); + expect("actionSorting", {"Ctrl+P"}); + expect("actionObjectRotateX", {"Ctrl+X"}); + expect("actionObjectRotateY", {"Up", "Ctrl+Y"}); + expect("actionObjectRotateZ", {"Right", "Ctrl+Z"}); + expect("actionObjectProperties", {"Return"}); + expect("actionLightRotateY", {"Ctrl+Up"}); + expect("actionLightRotateZ", {"Ctrl+Right"}); + expect("actionIncreaseAmbientLight", {"+", "="}); + expect("actionDecreaseAmbientLight", {"-"}); + expect("actionIncreaseSceneLight", {"Ctrl++", "Ctrl+="}); + expect("actionDecreaseSceneLight", {"Ctrl+-"}); + expect("actionKillSceneLight", {"Ctrl+*"}); + expect("actionCameraFront", {"Ctrl+F"}); + expect("actionCameraBack", {"Ctrl+B"}); + expect("actionCameraLeft", {"Ctrl+L"}); + expect("actionCameraRight", {"Ctrl+R"}); + expect("actionCameraTop", {"Ctrl+T"}); + expect("actionCameraBottom", {"Ctrl+M"}); + expect("actionCameraCopyScreen", {"Ctrl+C"}); + expect("actionCameraAnimate", {"F8"}); + expect("actionCameraDistance", {"Ctrl+D"}); + expect("actionFog", {"Ctrl+Alt+F"}); + expect("actionCaptureScreenshot", {"F7"}); + expect("shortcutMakeAggregate", {"Ctrl+A"}); + expect("shortcutAdvancedAnimation", {"Ctrl+V"}); + expect("shortcutLodRecordScreenArea", {"Space"}); + expect("shortcutLodPrevious", {"["}); + expect("shortcutLodNext", {"]"}); + expect("shortcutObjectRotateYBack", {"Down"}); + expect("shortcutObjectRotateZBack", {"Left"}); + expect("shortcutLightRotateYBack", {"Ctrl+Down"}); + expect("shortcutLightRotateZBack", {"Ctrl+Left"}); + for (int slot = 1; slot <= 9; ++slot) { + expected.insert(QString("shortcutQuickSettings%1").arg(slot), + QStringList{QString::number(slot)}); + } + expect("shortcutNextPane", {"F6"}); + expect("shortcutPreviousPane", {"Shift+F6"}); + + QMap actual; + QMap shortcutOwners; + for (QAction *candidate : _window->findChildren()) { + if (candidate->objectName().isEmpty() || candidate->shortcuts().isEmpty()) { + continue; + } + const QStringList shortcuts = portableShortcuts(*candidate); + actual.insert(candidate->objectName(), shortcuts); + QCOMPARE(candidate->shortcutContext(), Qt::WindowShortcut); + for (const QString &shortcut : shortcuts) { + QVERIFY2(!shortcutOwners.contains(shortcut), + qPrintable(QString("Shortcut %1 is assigned to both %2 and %3") + .arg(shortcut, + shortcutOwners.value(shortcut), + candidate->objectName()))); + shortcutOwners.insert(shortcut, candidate->objectName()); + } + } + + QVERIFY(actual.keys() == expected.keys()); + for (auto it = expected.cbegin(); it != expected.cend(); ++it) { + QVERIFY2(actual.value(it.key()) == it.value(), qPrintable(it.key())); + } +} + +void MainWindowCommandTests::emptyStartupCommandState() +{ + const char *disabledActions[] = { + "actionImportFacialAnims", + "actionExportAggregate", + "actionExportEmitter", + "actionExportLod", + "actionExportPrimitive", + "actionExportSoundObject", + "actionObjectProperties", + "actionScaleEmitter", + "actionEditEmitter", + "actionEditPrimitive", + "actionEditSoundObject", + "actionMakeMovie", + "actionCopyAssets", + "actionAddToLineup", + "actionToolbarAnimationPlay", + "actionToolbarAnimationStop", + "actionToolbarAnimationPause", + "actionToolbarAnimationStepBack", + "actionToolbarAnimationStepForward", + }; + for (const char *objectName : disabledActions) { + QAction *candidate = action(objectName); + QVERIFY(candidate); + QVERIFY2(!candidate->isEnabled(), objectName); + } + + const char *checkedActions[] = { + "actionToolbarMain", + "actionToolbarObject", + "actionStatusBar", + "actionAutoExpandAssetTree", + "actionSorting", + "actionRestrictAnims", + "actionCameraResetOnDisplay", + "actionPrelitMultipass", + "actionNpatchesLevel4", + }; + for (const char *objectName : checkedActions) { + QAction *candidate = action(objectName); + QVERIFY(candidate); + QVERIFY2(candidate->isChecked(), objectName); + } + + const char *uncheckedActions[] = { + "actionToolbarAnimation", + "actionWireframe", + "actionInvertBackfaceCulling", + "actionNpatchesGap", + "actionObjectRotateX", + "actionObjectRotateY", + "actionObjectRotateZ", + "actionLightRotateY", + "actionLightRotateZ", + "actionExposePrelit", + "actionPrelitVertex", + "actionPrelitMultitex", + "actionCameraRotateX", + "actionCameraRotateY", + "actionCameraRotateZ", + "actionCameraAnimate", + "actionCameraBonePosX", + "actionFog", + }; + for (const char *objectName : uncheckedActions) { + QAction *candidate = action(objectName); + QVERIFY(candidate); + QVERIFY2(!candidate->isChecked(), objectName); + } + + QToolBar *mainToolbar = _window->findChild("MainToolbar"); + QToolBar *objectToolbar = _window->findChild("ObjectToolbar"); + QToolBar *animationToolbar = _window->findChild("AnimationToolbar"); + QStatusBar *statusBar = _window->findChild("statusBar"); + QVERIFY(mainToolbar); + QVERIFY(objectToolbar); + QVERIFY(animationToolbar); + QVERIFY(statusBar); + QVERIFY(!mainToolbar->isHidden()); + QVERIFY(!objectToolbar->isHidden()); + QVERIFY(animationToolbar->isHidden()); + QVERIFY(!statusBar->isHidden()); +} + +void MainWindowCommandTests::selectionSpecificMenusAndEmptyPlaceholders() +{ + QMenuBar *menuBar = _window->findChild("menuBar"); + QTreeView *tree = _window->findChild("assetTreeView"); + QVERIFY(menuBar); + QVERIFY(tree); + QVERIFY(tree->selectionModel()); + + const QStringList baseMenus = toStringList({"menu:fileMenu", + "menu:settingsMenu", + "menu:viewMenu", + "menu:objectMenu", + "menu:emittersMenu", + "menu:primitivesMenu", + "menu:soundMenu", + "menu:lightingMenu", + "menu:cameraMenu", + "menu:backgroundMenu", + "menu:movieMenu", + "menu:helpMenu"}); + QVERIFY(commandIds(menuBar->actions()) == baseMenus); + + const struct { + const char *treePrefix; + const char *menuName; + } cases[] = { + {"Hierarchy", "hierarchyMenu"}, + {"H-LOD", "lodMenu"}, + {"Aggregate", "aggregateMenu"}, + }; + for (const auto &testCase : cases) { + const QModelIndex index = findRootItem(QString::fromLatin1(testCase.treePrefix)); + QVERIFY2(index.isValid(), testCase.treePrefix); + tree->setCurrentIndex(index); + + QStringList expected = baseMenus; + const int lightingIndex = expected.indexOf("menu:lightingMenu"); + QVERIFY(lightingIndex >= 0); + expected.insert(lightingIndex, "menu:" + QString::fromLatin1(testCase.menuName)); + QVERIFY(commandIds(menuBar->actions()) == expected); + } + + const QModelIndex materials = findRootItem("Materials"); + QVERIFY(materials.isValid()); + tree->setCurrentIndex(materials); + QVERIFY(commandIds(menuBar->actions()) == baseMenus); + + QVERIFY(!_window->findChild("recentFilesMenu")); + QAction *recentFilesPlaceholder = action("actionRecentFilesPlaceholder"); + QCOMPARE(recentFilesPlaceholder->text(), QString("Recent File")); + QVERIFY(recentFilesPlaceholder->isVisible()); + QVERIFY(!recentFilesPlaceholder->isEnabled()); + + QMenu *emittersEditMenu = _window->findChild("emittersEditMenu"); + QVERIFY(emittersEditMenu); + QVERIFY(QMetaObject::invokeMethod(emittersEditMenu, "aboutToShow", Qt::DirectConnection)); + QCOMPARE(emittersEditMenu->actions().size(), 1); + QCOMPARE(emittersEditMenu->actions().first()->text(), QString("(No Emitters)")); + QVERIFY(!emittersEditMenu->actions().first()->isEnabled()); + + QMenu *lodMenu = _window->findChild("lodMenu"); + QVERIFY(lodMenu); + QVERIFY(QMetaObject::invokeMethod(lodMenu, "aboutToShow", Qt::DirectConnection)); + QVERIFY(!action("actionLodPrevious")->isEnabled()); + QVERIFY(!action("actionLodNext")->isEnabled()); +} + +void MainWindowCommandTests::recentFilesMatchMfcPresentationAndLimit() +{ + QStringList paths; + for (int index = 1; index <= 11; ++index) { + paths.append(QDir(_settingsDirectory->path()).filePath(QString("file%1.w3d").arg(index))); + } + + QSettings settings; + settings.setValue("recentFiles", paths); + settings.sync(); + + { + W3DViewMainWindow recentWindow; + for (QTimer *timer : recentWindow.findChildren()) { + timer->stop(); + } + + QVERIFY(!recentWindow.findChild("recentFilesMenu")); + QMenu *fileMenu = recentWindow.findChild("fileMenu"); + QAction *placeholder = + recentWindow.findChild("actionRecentFilesPlaceholder"); + QVERIFY(fileMenu); + QVERIFY(placeholder); + QVERIFY(!placeholder->isVisible()); + QVERIFY(!placeholder->isEnabled()); + + const QList actions = fileMenu->actions(); + const int placeholderIndex = actions.indexOf(placeholder); + QVERIFY(placeholderIndex >= 9); + QAction *firstRecentAction = nullptr; + for (int index = 1; index <= 9; ++index) { + QAction *recentAction = actions.at(placeholderIndex - 9 + index - 1); + if (index == 1) { + firstRecentAction = recentAction; + } + QCOMPARE(recentAction->objectName(), QString("recentFileAction%1").arg(index)); + QCOMPARE(recentAction->text(), QString("&%1 file%1.w3d").arg(index)); + QCOMPARE(recentAction->data().toString(), paths.at(index - 1)); + QVERIFY(recentAction->isVisible()); + } + + settings.sync(); + QCOMPARE(settings.value("recentFiles").toStringList().size(), 9); + QVERIFY(firstRecentAction); + QTimer::singleShot(0, &recentWindow, []() { + if (QWidget *modal = QApplication::activeModalWidget()) { + modal->close(); + } + }); + firstRecentAction->trigger(); + + settings.sync(); + const QStringList remaining = settings.value("recentFiles").toStringList(); + QCOMPARE(remaining.size(), 8); + QVERIFY(!remaining.contains(paths.first())); + QVERIFY(!recentWindow.findChild("recentFileAction9")); + } + + settings.remove("recentFiles"); + settings.sync(); +} + +void MainWindowCommandTests::settingsFilesAreValidatedBeforeApply() +{ + W3DViewport *viewport = _window->findChild("viewport"); + QVERIFY(viewport); + QAction *fogAction = action("actionFog"); + fogAction->setChecked(false); + viewport->setFogEnabled(false); + + const QString validPath = QDir(_settingsDirectory->path()).filePath("valid-settings.dat"); + QFile validFile(validPath); + QVERIFY(validFile.open(QIODevice::WriteOnly | QIODevice::Truncate)); + QCOMPARE(validFile.write("[Settings]\nFogEnabled=true\n"), qint64(27)); + validFile.close(); + + QVERIFY(_window->loadSettingsPath(validPath)); + QVERIFY(viewport->isFogEnabled()); + fogAction->setChecked(false); + viewport->setFogEnabled(false); + QVERIFY(!viewport->isFogEnabled()); + + const QString malformedPath = + QDir(_settingsDirectory->path()).filePath("malformed-settings.dat"); + QFile malformedFile(malformedPath); + QVERIFY(malformedFile.open(QIODevice::WriteOnly | QIODevice::Truncate)); + QVERIFY(malformedFile.write("[Settings]\nFogEnabled=true\n[Broken\n") > 0); + malformedFile.close(); + + QVERIFY(!_window->loadSettingsPath(malformedPath)); + QVERIFY(!viewport->isFogEnabled()); +} + +void MainWindowCommandTests::relativeSettingsPathResolvesBesideExecutable() +{ + SaveSettingsDialog dialog; + const QString expected = + QDir(QCoreApplication::applicationDirPath()).filePath(QStringLiteral("Default.dat")); + QCOMPARE(QFileInfo(dialog.selectedPath()).absoluteFilePath(), + QFileInfo(expected).absoluteFilePath()); +} + +void MainWindowCommandTests::emptyTexturePathsRemainEmpty() +{ + QSettings settings; + settings.remove("Config/TexturePath1"); + settings.remove("Config/TexturePath2"); + settings.sync(); + + QString interactionFailure; + bool acceptedDialog = false; + QTimer::singleShot(0, _window.get(), [&interactionFailure, &acceptedDialog]() { + auto *dialog = qobject_cast(QApplication::activeModalWidget()); + if (!dialog || dialog->objectName() != "TexturePathDialog") { + interactionFailure = "The Texture Path dialog did not become active"; + if (dialog) { + dialog->reject(); + } + return; + } + + QLineEdit *path1 = dialog->findChild("path1LineEdit"); + QLineEdit *path2 = dialog->findChild("path2LineEdit"); + if (!path1 || !path2) { + interactionFailure = "The Texture Path line edits were not found"; + dialog->reject(); + return; + } + + path1->setText(" "); + path2->clear(); + acceptedDialog = true; + dialog->accept(); + }); + + action("actionTexturePaths")->trigger(); + QVERIFY2(interactionFailure.isEmpty(), qPrintable(interactionFailure)); + QVERIFY(acceptedDialog); + + settings.sync(); + QVERIFY(!settings.contains("Config/TexturePath1")); + QVERIFY(!settings.contains("Config/TexturePath2")); +} + +void MainWindowCommandTests::startupManualClipDefaultsMatchMfc() +{ + QSettings settings; + settings.setValue("Config/UseManualClipPlanes", true); + settings.remove("Config/znear"); + settings.remove("Config/zfar"); + settings.sync(); + + { + W3DViewMainWindow window; + W3DViewport *viewport = window.findChild("viewport"); + QVERIFY(viewport); + QVERIFY(viewport->isManualClipPlanesEnabled()); + + float nearClip = 0.0f; + float farClip = 0.0f; + viewport->cameraClipPlanes(nearClip, farClip); + QCOMPARE(nearClip, 0.1f); + QCOMPARE(farClip, 100.0f); + } + + settings.remove("Config/UseManualClipPlanes"); + settings.sync(); +} + +void MainWindowCommandTests::safeActionWiring() +{ + W3DViewport *viewport = _window->findChild("viewport"); + QStatusBar *statusBar = _window->findChild("statusBar"); + QToolBar *mainToolbar = _window->findChild("MainToolbar"); + QToolBar *animationToolbar = _window->findChild("AnimationToolbar"); + QVERIFY(viewport); + QVERIFY(statusBar); + QVERIFY(mainToolbar); + QVERIFY(animationToolbar); + QVERIFY(!viewport->isVisible()); + + action("actionWireframe")->trigger(); + QVERIFY(viewport->isWireframeEnabled()); + action("actionWireframe")->trigger(); + QVERIFY(!viewport->isWireframeEnabled()); + + action("actionFog")->trigger(); + QVERIFY(viewport->isFogEnabled()); + action("actionFog")->trigger(); + QVERIFY(!viewport->isFogEnabled()); + + action("actionObjectRotateX")->trigger(); + QCOMPARE(viewport->objectRotationFlags(), static_cast(W3DViewport::RotateX)); + action("actionObjectRotateX")->trigger(); + QCOMPARE(viewport->objectRotationFlags(), static_cast(W3DViewport::RotateNone)); + + action("shortcutObjectRotateYBack")->trigger(); + QCOMPARE(viewport->objectRotationFlags(), static_cast(W3DViewport::RotateYBack)); + action("shortcutObjectRotateYBack")->trigger(); + QCOMPARE(viewport->objectRotationFlags(), static_cast(W3DViewport::RotateNone)); + + action("actionLightRotateY")->trigger(); + QCOMPARE(viewport->lightRotationFlags(), static_cast(W3DViewport::RotateY)); + action("actionLightRotateY")->trigger(); + QCOMPARE(viewport->lightRotationFlags(), static_cast(W3DViewport::RotateNone)); + + action("shortcutLightRotateZBack")->trigger(); + QCOMPARE(viewport->lightRotationFlags(), static_cast(W3DViewport::RotateZBack)); + action("shortcutLightRotateZBack")->trigger(); + QCOMPARE(viewport->lightRotationFlags(), static_cast(W3DViewport::RotateNone)); + + action("actionCameraRotateX")->trigger(); + QCOMPARE(static_cast(viewport->allowedCameraRotation()), + static_cast(W3DViewport::CameraRotation::OnlyX)); + action("actionCameraRotateY")->trigger(); + QCOMPARE(static_cast(viewport->allowedCameraRotation()), + static_cast(W3DViewport::CameraRotation::OnlyY)); + QVERIFY(!action("actionCameraRotateX")->isChecked()); + action("actionCameraRotateY")->trigger(); + QCOMPARE(static_cast(viewport->allowedCameraRotation()), + static_cast(W3DViewport::CameraRotation::Free)); + + action("actionCameraAnimate")->trigger(); + QVERIFY(viewport->isCameraAnimationEnabled()); + action("actionCameraAnimate")->trigger(); + QVERIFY(!viewport->isCameraAnimationEnabled()); + + action("actionCameraResetOnDisplay")->trigger(); + QVERIFY(!viewport->isAutoResetEnabled()); + action("actionCameraResetOnDisplay")->trigger(); + QVERIFY(viewport->isAutoResetEnabled()); + + action("actionStatusBar")->trigger(); + QVERIFY(statusBar->isHidden()); + action("actionStatusBar")->trigger(); + QVERIFY(!statusBar->isHidden()); + + action("actionToolbarMain")->trigger(); + QVERIFY(mainToolbar->isHidden()); + action("actionToolbarMain")->trigger(); + QVERIFY(!mainToolbar->isHidden()); + + action("actionToolbarAnimation")->trigger(); + QVERIFY(!animationToolbar->isHidden()); + action("actionToolbarAnimation")->trigger(); + QVERIFY(animationToolbar->isHidden()); + + QVERIFY2(!viewport->isVisible(), "A command unexpectedly showed the Direct3D viewport"); +} + +void MainWindowCommandTests::restoredToolbarStateStaysSynchronized() +{ + QAction *mainAction = action("actionToolbarMain"); + QAction *objectAction = action("actionToolbarObject"); + QToolBar *mainToolbar = _window->findChild("MainToolbar"); + QToolBar *objectToolbar = _window->findChild("ObjectToolbar"); + QVERIFY(mainAction); + QVERIFY(objectAction); + QVERIFY(mainToolbar); + QVERIFY(objectToolbar); + QVERIFY(mainAction->isChecked()); + QVERIFY(objectAction->isChecked()); + + mainAction->trigger(); + objectAction->trigger(); + QVERIFY(mainToolbar->isHidden()); + QVERIFY(objectToolbar->isHidden()); + const QByteArray hiddenToolbarState = _window->saveState(); + QVERIFY(!hiddenToolbarState.isEmpty()); + + mainAction->trigger(); + objectAction->trigger(); + QVERIFY(!mainToolbar->isHidden()); + QVERIFY(!objectToolbar->isHidden()); + + QSettings settings; + settings.setValue("Window/State", hiddenToolbarState); + settings.sync(); + { + W3DViewMainWindow restoredWindow; + for (QTimer *timer : restoredWindow.findChildren()) { + timer->stop(); + } + + QToolBar *restoredMainToolbar = + restoredWindow.findChild("MainToolbar"); + QToolBar *restoredObjectToolbar = + restoredWindow.findChild("ObjectToolbar"); + QAction *restoredMainAction = + restoredWindow.findChild("actionToolbarMain"); + QAction *restoredObjectAction = + restoredWindow.findChild("actionToolbarObject"); + W3DViewport *restoredViewport = + restoredWindow.findChild("viewport"); + QVERIFY(restoredMainToolbar); + QVERIFY(restoredObjectToolbar); + QVERIFY(restoredMainAction); + QVERIFY(restoredObjectAction); + QVERIFY(restoredViewport); + QVERIFY(restoredMainToolbar->isHidden()); + QVERIFY(restoredObjectToolbar->isHidden()); + QVERIFY(!restoredMainAction->isChecked()); + QVERIFY(!restoredObjectAction->isChecked()); + QVERIFY(!restoredViewport->isVisible()); + } + settings.remove("Window/State"); + settings.sync(); +} + +void MainWindowCommandTests::aggregateSubobjectNamesAreBounded() +{ + QTemporaryDir fixtureDirectory; + QVERIFY2(fixtureDirectory.isValid(), "Could not create the aggregate boundary fixture directory"); + + const QString inputPath = QDir(fixtureDirectory.path()).filePath("aggregate-input.w3d"); + const QString outputPath = QDir(fixtureDirectory.path()).filePath("aggregate-output.w3d"); + + W3dAggregateInfoStruct inputInfo = {}; + setW3dName(inputInfo.BaseModelName, "BOUNDARY_BASE"); + inputInfo.SubobjectCount = 1; + + W3dAggregateSubobjectStruct inputSubobject; + std::memset(inputSubobject.SubobjectName, 'S', sizeof(inputSubobject.SubobjectName)); + std::memset(inputSubobject.BoneName, 'B', sizeof(inputSubobject.BoneName)); + + { + const QByteArray nativePath = QDir::toNativeSeparators(inputPath).toLocal8Bit(); + RawFileClass file(nativePath.constData()); + QVERIFY(file.Open(FileClass::WRITE)); + ChunkSaveClass save(&file); + QVERIFY(save.Begin_Chunk(W3D_CHUNK_AGGREGATE)); + QVERIFY(save.Begin_Chunk(W3D_CHUNK_AGGREGATE_INFO)); + QCOMPARE(save.Write(&inputInfo, sizeof(inputInfo)), + static_cast(sizeof(inputInfo))); + QCOMPARE(save.Write(&inputSubobject, sizeof(inputSubobject)), + static_cast(sizeof(inputSubobject))); + QVERIFY(save.End_Chunk()); + QVERIFY(save.End_Chunk()); + file.Close(); + } + + AggregateDefClass definition; + { + const QByteArray nativePath = QDir::toNativeSeparators(inputPath).toLocal8Bit(); + RawFileClass file(nativePath.constData()); + QVERIFY(file.Open(FileClass::READ)); + ChunkLoadClass load(&file); + QVERIFY(load.Open_Chunk()); + QCOMPARE(load.Cur_Chunk_ID(), static_cast(W3D_CHUNK_AGGREGATE)); + QCOMPARE(definition.Load_W3D(load), WW3D_ERROR_OK); + QVERIFY(load.Close_Chunk()); + file.Close(); + } + + definition.Set_Name("BOUNDARY_AGGREGATE"); + { + const QByteArray nativePath = QDir::toNativeSeparators(outputPath).toLocal8Bit(); + RawFileClass file(nativePath.constData()); + QVERIFY(file.Open(FileClass::WRITE)); + ChunkSaveClass save(&file); + QCOMPARE(definition.Save_W3D(save), WW3D_ERROR_OK); + file.Close(); + } + + W3dAggregateSubobjectStruct savedSubobject = {}; + bool foundInfo = false; + { + const QByteArray nativePath = QDir::toNativeSeparators(outputPath).toLocal8Bit(); + RawFileClass file(nativePath.constData()); + QVERIFY(file.Open(FileClass::READ)); + ChunkLoadClass load(&file); + QVERIFY(load.Open_Chunk()); + QCOMPARE(load.Cur_Chunk_ID(), static_cast(W3D_CHUNK_AGGREGATE)); + while (load.Open_Chunk()) { + if (load.Cur_Chunk_ID() == W3D_CHUNK_AGGREGATE_INFO) { + W3dAggregateInfoStruct savedInfo = {}; + QCOMPARE(load.Read(&savedInfo, sizeof(savedInfo)), + static_cast(sizeof(savedInfo))); + QCOMPARE(savedInfo.SubobjectCount, static_cast(1)); + QCOMPARE(load.Read(&savedSubobject, sizeof(savedSubobject)), + static_cast(sizeof(savedSubobject))); + foundInfo = true; + } + QVERIFY(load.Close_Chunk()); + } + QVERIFY(load.Close_Chunk()); + file.Close(); + } + + QVERIFY(foundInfo); + QCOMPARE(inputSubobject.SubobjectName[sizeof(inputSubobject.SubobjectName) - 1], 'S'); + QCOMPARE(inputSubobject.BoneName[sizeof(inputSubobject.BoneName) - 1], 'B'); + QCOMPARE(savedSubobject.SubobjectName[sizeof(savedSubobject.SubobjectName) - 1], '\0'); + QCOMPARE(savedSubobject.BoneName[sizeof(savedSubobject.BoneName) - 1], '\0'); +} + +void MainWindowCommandTests::soundPrototypeRegistrationRejectsCollisions() +{ + auto *assetManager = WW3DAssetManager::Get_Instance(); + QVERIFY(assetManager); + + constexpr const char *firstName = "qt_snd_a"; + constexpr const char *secondName = "qt_snd_b"; + struct PrototypeCleanup final + { + WW3DAssetManager *manager = nullptr; + ~PrototypeCleanup() + { + if (manager) { + manager->Remove_Prototype("qt_snd_a"); + manager->Remove_Prototype("qt_snd_b"); + } + } + } cleanup{assetManager}; + + assetManager->Remove_Prototype(firstName); + assetManager->Remove_Prototype(secondName); + + SoundRenderObjClass first; + first.Set_Name(firstName); + QString errorMessage; + QVERIFY2(UpdateSoundPrototype(first, QString(), &errorMessage), qPrintable(errorMessage)); + PrototypeClass *firstPrototype = assetManager->Find_Prototype(firstName); + QVERIFY(firstPrototype); + + SoundRenderObjClass second; + second.Set_Name(secondName); + errorMessage.clear(); + QVERIFY2(UpdateSoundPrototype(second, QString(), &errorMessage), qPrintable(errorMessage)); + PrototypeClass *secondPrototype = assetManager->Find_Prototype(secondName); + QVERIFY(secondPrototype); + + second.Set_Name(firstName); + errorMessage.clear(); + QVERIFY(!UpdateSoundPrototype(second, secondName, &errorMessage)); + QVERIFY(errorMessage.contains("already exists", Qt::CaseInsensitive)); + QCOMPARE(assetManager->Find_Prototype(firstName), firstPrototype); + QCOMPARE(assetManager->Find_Prototype(secondName), secondPrototype); + + SoundRenderObjClass duplicate; + duplicate.Set_Name(firstName); + errorMessage.clear(); + QVERIFY(!UpdateSoundPrototype(duplicate, QString(), &errorMessage)); + QCOMPARE(assetManager->Find_Prototype(firstName), firstPrototype); + + errorMessage.clear(); + QVERIFY2(UpdateSoundPrototype(first, firstName, &errorMessage), qPrintable(errorMessage)); + QVERIFY(assetManager->Find_Prototype(firstName)); + QVERIFY(assetManager->Find_Prototype(firstName) != firstPrototype); + QCOMPARE(assetManager->Find_Prototype(secondName), secondPrototype); +} + +void MainWindowCommandTests::soundSerializerReportsOneShotWriteFailure() +{ + std::array storage = {}; + OneShotShortWriteRAMFile file(storage.data(), static_cast(storage.size())); + QVERIFY(file.Open(FileClass::WRITE)); + + ChunkSaveClass save(&file); + SoundRenderObjDefClass definition; + definition.Set_Name("WRITE_FAILURE_SOUND"); + + QCOMPARE(definition.Save_W3D(save), WW3D_ERROR_SAVE_FAILED); + QVERIFY(file.failed()); + QVERIFY(save.Has_Write_Error()); + QCOMPARE(save.Cur_Chunk_Depth(), 0); + file.Close(); +} + +void MainWindowCommandTests::generatedHierarchyAnimationFixture() +{ + QTemporaryDir fixtureDirectory; + QVERIFY2(fixtureDirectory.isValid(), "Could not create the generated W3D fixture directory"); + [[maybe_unused]] CurrentDirectoryRestorer restoreCurrentDirectory; + + const QString fixturePath = + QDir(fixtureDirectory.path()).filePath("generated-animation.w3d"); + QVERIFY2(writeGeneratedAnimationFixture(fixturePath), + "Could not write the generated hierarchy-animation fixture"); + QVERIFY2(_window->openFilePath(fixturePath), + "W3DViewQt rejected the generated hierarchy-animation fixture"); + + auto *assetManager = WW3DAssetManager::Get_Instance(); + QVERIFY(assetManager); + + const QStringList hierarchyNames = + collectAssetNames(assetManager->Create_HTree_Iterator()); + QVERIFY(hierarchyNames.contains("TEST_RIG")); + QVERIFY(hierarchyNames.contains("OTHER_RIG")); + + HTreeClass *testTree = assetManager->Get_HTree("TEST_RIG"); + QVERIFY(testTree); + QCOMPARE(testTree->Num_Pivots(), 1); + + const QStringList animationNames = + collectAssetNames(assetManager->Create_HAnim_Iterator()); + QCOMPARE(animationNames.count("TEST_RIG.TEST_MOVE"), 1); + + std::unique_ptr> animation( + assetManager->Get_HAnim("TEST_RIG.TEST_MOVE")); + QVERIFY(animation); + QCOMPARE(QString::fromLatin1(animation->Get_HName()), QString("TEST_RIG")); + QCOMPARE(animation->Get_Num_Frames(), 2); + QCOMPARE(animation->Get_Frame_Rate(), 30.0f); + QVERIFY(animation->Has_X_Translation(0)); + Vector3 translation; + animation->Get_Translation(translation, 0, 1.0f); + QCOMPARE(translation.X, 1.25f); + + QVERIFY(assetManager->Render_Obj_Exists("TEST_MODEL")); + QVERIFY(assetManager->Render_Obj_Exists("OTHER_MODEL")); + std::unique_ptr> renderObject( + assetManager->Create_Render_Obj("TEST_MODEL")); + QVERIFY(renderObject); + QCOMPARE(renderObject->Class_ID(), static_cast(RenderObjClass::CLASSID_HLOD)); + QVERIFY(renderObject->Get_HTree()); + QCOMPARE(QString::fromLatin1(renderObject->Get_HTree()->Get_Name()), QString("TEST_RIG")); + + QTreeView *treeView = _window->findChild("assetTreeView"); + W3DViewport *viewport = _window->findChild("viewport"); + QVERIFY(treeView); + QVERIFY(viewport); + auto *model = qobject_cast(treeView->model()); + QVERIFY(model); + + const QModelIndex hierarchyGroup = findRootItem("Hierarchy"); + QVERIFY(hierarchyGroup.isValid()); + QCOMPARE(hierarchyGroup.data().toString(), QString("Hierarchy (2)")); + + const QModelIndex testModel = findDirectChild(model, hierarchyGroup, "TEST_MODEL"); + const QModelIndex otherModel = findDirectChild(model, hierarchyGroup, "OTHER_MODEL"); + QVERIFY(testModel.isValid()); + QVERIFY(otherModel.isValid()); + QCOMPARE(model->rowCount(testModel), 1); + QCOMPARE(model->rowCount(otherModel), 0); + + const QModelIndex testAnimation = + findDirectChild(model, testModel, "TEST_RIG.TEST_MOVE"); + QVERIFY2(testAnimation.isValid(), + "The generated animation was not attached beneath its matching hierarchy model"); + + treeView->setCurrentIndex(testAnimation); + QCoreApplication::processEvents(); + QVERIFY(viewport->hasAnimation()); + QCOMPARE(viewport->currentAnimationName(), QString("TEST_RIG.TEST_MOVE")); + QVERIFY(viewport->currentRenderObject()); + QCOMPARE(QString::fromLatin1(viewport->currentRenderObject()->Get_Name()), + QString("TEST_MODEL")); + QCOMPARE(viewport->animationState(), W3DViewport::AnimationState::Playing); + QVERIFY(action("actionMakeMovie")->isEnabled()); + + int currentFrame = -1; + int totalFrames = 0; + float framesPerSecond = 0.0f; + QVERIFY(viewport->animationStatus(currentFrame, totalFrames, framesPerSecond)); + QCOMPARE(currentFrame, 0); + QCOMPARE(totalFrames, 2); + QCOMPARE(framesPerSecond, 30.0f); + + AnimationPropertiesDialog properties("TEST_RIG.TEST_MOVE"); + QLabel *frameCountValue = properties.findChild("frameCountValue"); + QLabel *frameRateValue = properties.findChild("frameRateValue"); + QLabel *hierarchyNameValue = properties.findChild("hierarchyNameValue"); + QVERIFY(frameCountValue); + QVERIFY(frameRateValue); + QVERIFY(hierarchyNameValue); + QCOMPARE(frameCountValue->text(), QString("2")); + QCOMPARE(frameRateValue->text(), QString("30.00 fps")); + QCOMPARE(hierarchyNameValue->text(), QString("TEST_RIG")); + + AnimationSettingsDialog settings(*viewport); + QSlider *speedSlider = settings.findChild("speedSlider"); + QCheckBox *blendCheckBox = settings.findChild("blendCheckBox"); + QVERIFY(speedSlider); + QVERIFY(blendCheckBox); + speedSlider->setValue(150); + blendCheckBox->setChecked(false); + settings.reject(); + QCOMPARE(viewport->animationSpeed(), 1.5f); + QVERIFY(!viewport->animationBlend()); + + AdvancedAnimationDialog advanced(viewport, "TEST_MODEL"); + QListWidget *mixingList = advanced.findChild("mixingListWidget"); + QVERIFY(mixingList); + QCOMPARE(mixingList->count(), 1); + QCOMPARE(mixingList->item(0)->text(), QString("TEST_RIG.TEST_MOVE")); + mixingList->item(0)->setSelected(true); + QDialogButtonBox *advancedButtons = + advanced.findChild("buttonBox"); + QVERIFY(advancedButtons); + QVERIFY(advancedButtons->button(QDialogButtonBox::Ok)); + advancedButtons->button(QDialogButtonBox::Ok)->click(); + QCOMPARE(advanced.result(), static_cast(QDialog::Accepted)); + QVERIFY(viewport->hasAnimation()); + QCOMPARE(viewport->currentAnimationName(), QString("TEST_RIG.TEST_MOVE")); + QCOMPARE(QString::fromLatin1(viewport->currentRenderObject()->Get_Name()), + QString("TEST_MODEL")); + + action("actionToolbarAnimationStop")->trigger(); + QCOMPARE(viewport->animationState(), W3DViewport::AnimationState::Stopped); + action("actionToolbarAnimationStepForward")->trigger(); + QVERIFY(viewport->animationStatus(currentFrame, totalFrames, framesPerSecond)); + QCOMPARE(currentFrame, 1); +} + +void MainWindowCommandTests::externalAnimationAssetBundle() +{ + const QString assetDirectory = qEnvironmentVariable("W3DVIEW_EXTERNAL_ASSET_DIR"); + if (assetDirectory.isEmpty()) { + QSKIP("Set W3DVIEW_EXTERNAL_ASSET_DIR to run the real-asset animation integration test"); + } + [[maybe_unused]] CurrentDirectoryRestorer restoreCurrentDirectory; + + const QStringList assetNames = { + "s_a_human.w3d", + "s_a_head.w3d", + "c_nod_ksma_l0.w3d", + "c_nod_ksma_.w3d", + "c_nod_ksma_head.w3d", + "c_ag_nod_ksma.w3d", + "h_a_cresentkick.w3d", + }; + for (const QString &name : assetNames) { + const QString path = QDir(assetDirectory).filePath(name); + QVERIFY2(QFileInfo::exists(path), qPrintable(QString("Missing integration asset: %1").arg(path))); + QVERIFY2(_window->openFilePath(path), qPrintable(QString("Failed to load integration asset: %1").arg(path))); + } + + QTreeView *treeView = _window->findChild("assetTreeView"); + W3DViewport *viewport = _window->findChild("viewport"); + QVERIFY(treeView); + QVERIFY(viewport); + + auto *model = qobject_cast(treeView->model()); + QVERIFY(model); + const QModelIndex hierarchyGroup = findRootItem("Hierarchy"); + QVERIFY(hierarchyGroup.isValid()); + + QModelIndex kaneHierarchy; + for (int row = 0; row < model->rowCount(hierarchyGroup); ++row) { + const QModelIndex candidate = model->index(row, 0, hierarchyGroup); + if (candidate.data().toString() == "C_NOD_KSMA_") { + kaneHierarchy = candidate; + break; + } + } + QVERIFY(kaneHierarchy.isValid()); + + QModelIndex kickAnimation; + for (int row = 0; row < model->rowCount(kaneHierarchy); ++row) { + const QModelIndex candidate = model->index(row, 0, kaneHierarchy); + if (candidate.data().toString() == "S_A_HUMAN.H_A_CRESENTKICK") { + kickAnimation = candidate; + break; + } + } + QVERIFY2(kickAnimation.isValid(), "The matching Kane animation was not attached beneath C_NOD_KSMA_"); + + treeView->setCurrentIndex(kickAnimation); + QCoreApplication::processEvents(); + QVERIFY(viewport->hasAnimation()); + QCOMPARE(viewport->currentAnimationName(), QString("S_A_HUMAN.H_A_CRESENTKICK")); + QCOMPARE(viewport->animationState(), W3DViewport::AnimationState::Playing); + + action("actionToolbarAnimationStop")->trigger(); + QCOMPARE(viewport->animationState(), W3DViewport::AnimationState::Stopped); + action("actionToolbarAnimationStepForward")->trigger(); + + int currentFrame = -1; + int totalFrames = 0; + float framesPerSecond = 0.0f; + QVERIFY(viewport->animationStatus(currentFrame, totalFrames, framesPerSecond)); + QCOMPARE(currentFrame, 1); +} + +void MainWindowCommandTests::externalRealAssetBundle() +{ + const QString assetDirectory = qEnvironmentVariable("W3DVIEW_EXTERNAL_ASSET_DIR"); + if (assetDirectory.isEmpty()) { + QSKIP("Set W3DVIEW_EXTERNAL_ASSET_DIR to run the real aggregate, sound, emitter, " + "sphere, ring, and HLOD integration test"); + } + [[maybe_unused]] CurrentDirectoryRestorer restoreCurrentDirectory; + + auto *assetManager = WW3DAssetManager::Get_Instance(); + QVERIFY(assetManager); + + const QStringList assetNames = { + "s_a_human.w3d", + "s_a_head.w3d", + "c_gdi_mgo_l0.w3d", + "c_gdi_mgo_l1.w3d", + "c_gdi_mgo_l2.w3d", + "c_gdi_mgo_l3.w3d", + "c_gdi_mgo_.w3d", + "c_gdi_mgo_head.w3d", + "c_ag_gdi_mgo.w3d", + "s_b_human.w3d", + "c_nod_sk_l3.w3d", + "c_nod_sk_l2.w3d", + "c_nod_sk_l1.w3d", + "c_nod_sk_l0.w3d", + "c_nod_sk_.w3d", + "e_flare02.w3d", + "xg_ionc_shock0.w3d", + "xg_ionc_shock1.w3d", + }; + for (const QString &name : assetNames) { + const QString path = QDir(assetDirectory).filePath(name); + QVERIFY2(QFileInfo::exists(path), + qPrintable(QString("Missing integration asset: %1").arg(path))); + QVERIFY2(_window->openFilePath(path), + qPrintable(QString("Failed to load integration asset: %1").arg(path))); + } + + // Loading through W3DViewMainWindow rebuilds the tree and instantiates + // each prototype. The native application owns an initialized audio + // singleton, while this offscreen test intentionally does not initialize + // audio or Direct3D, so load the sound definition directly. + const QString soundSource = QDir(assetDirectory).filePath("a10_loop.w3d"); + QVERIFY2(QFileInfo::exists(soundSource), + qPrintable(QString("Missing integration asset: %1").arg(soundSource))); + const QByteArray soundSourceNative = + QDir::toNativeSeparators(soundSource).toLocal8Bit(); + QVERIFY(assetManager->Load_3D_Assets(soundSourceNative.constData())); + + auto *aggregatePrototype = dynamic_cast( + assetManager->Find_Prototype("c_ag_gdi_mgo")); + QVERIFY(aggregatePrototype); + AggregateDefClass *aggregateDefinition = aggregatePrototype->Get_Definition(); + QVERIFY(aggregateDefinition); + QCOMPARE(QString::fromLatin1(aggregateDefinition->Get_Name()), QString("c_ag_gdi_mgo")); + QCOMPARE(QString::fromLatin1(aggregateDefinition->Get_Base_Model_Name()), + QString("C_GDI_MGO_")); + + std::unique_ptr> aggregateObject( + assetManager->Create_Render_Obj("c_ag_gdi_mgo")); + QVERIFY(aggregateObject); + const int headBone = aggregateObject->Get_Bone_Index("C HEAD"); + QVERIFY(headBone >= 0); + bool foundHead = false; + for (int index = 0; index < aggregateObject->Get_Num_Sub_Objects_On_Bone(headBone); ++index) { + std::unique_ptr> subobject( + aggregateObject->Get_Sub_Object_On_Bone(index, headBone)); + if (subobject && subobject->Get_Name() && + QString::fromLatin1(subobject->Get_Name()).compare( + "C_GDI_MGO_HEAD", Qt::CaseInsensitive) == 0) { + foundHead = true; + } + } + QVERIFY2(foundHead, "The real aggregate did not attach C_GDI_MGO_HEAD to C HEAD"); + + auto *soundPrototype = dynamic_cast( + assetManager->Find_Prototype("A10_Loop")); + QVERIFY(soundPrototype); + SoundRenderObjDefClass *soundDefinition = soundPrototype->Peek_Definition(); + QVERIFY(soundDefinition); + QCOMPARE(QString::fromLatin1(soundDefinition->Get_Name()), QString("A10_Loop")); + + auto *emitterPrototype = dynamic_cast( + assetManager->Find_Prototype("e_flare02")); + QVERIFY(emitterPrototype); + ParticleEmitterDefClass *emitterDefinition = emitterPrototype->Get_Definition(); + QVERIFY(emitterDefinition); + QCOMPARE(QString::fromLatin1(emitterDefinition->Get_Name()), QString("e_flare02")); + + auto *spherePrototype = dynamic_cast( + assetManager->Find_Prototype("XG_IonC_Shock0")); + QVERIFY(spherePrototype); + QCOMPARE(QString::fromLatin1(spherePrototype->Get_Name()), QString("XG_IonC_Shock0")); + + auto *ringPrototype = dynamic_cast( + assetManager->Find_Prototype("XG_IonC_Shock1")); + QVERIFY(ringPrototype); + QCOMPARE(QString::fromLatin1(ringPrototype->Get_Name()), QString("XG_IonC_Shock1")); + + auto *lodPrototype = dynamic_cast( + assetManager->Find_Prototype("C_NOD_SK_")); + QVERIFY(lodPrototype); + HLodDefClass *lodDefinition = lodPrototype->Get_Definition(); + QVERIFY(lodDefinition); + QCOMPARE(QString::fromLatin1(lodDefinition->Get_Name()), QString("C_NOD_SK_")); + + QTemporaryDir outputDirectory; + QVERIFY2(outputDirectory.isValid(), "Could not create the real-asset round-trip directory"); + QString exportError; + const auto saveAtomically = [&exportError]( + const QString &path, + std::uint32_t expectedChunk, + const W3DExportUtils::ChunkWriter &writer) { + exportError.clear(); + return W3DExportUtils::SaveChunkFileAtomically( + path, expectedChunk, writer, &exportError); + }; + const auto loadSingleDefinition = []( + const QString &path, + std::uint32_t expectedChunk, + const std::function &load) { + const QByteArray nativePath = QFile::encodeName(QDir::toNativeSeparators(path)); + RawFileClass file(nativePath.constData()); + if (!file.Open(FileClass::READ)) { + return false; + } + + const int fileSize = file.Size(); + ChunkLoadClass chunkLoad(&file); + const bool opened = chunkLoad.Open_Chunk(); + const bool correctChunk = opened && chunkLoad.Cur_Chunk_ID() == expectedChunk; + const bool loaded = correctChunk && load(chunkLoad); + const bool closed = opened && chunkLoad.Close_Chunk(); + const bool consumedFile = file.Tell() == fileSize; + file.Close(); + return opened && correctChunk && loaded && closed && consumedFile; + }; + const auto fileBytes = [](const QString &path) { + QFile file(path); + return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray(); + }; + + const QString aggregateFirst = QDir(outputDirectory.path()).filePath("aggregate-first.w3d"); + const QString aggregateSecond = QDir(outputDirectory.path()).filePath("aggregate-second.w3d"); + QVERIFY2(saveAtomically( + aggregateFirst, + W3D_CHUNK_AGGREGATE, + [aggregateDefinition](ChunkSaveClass &save) { + return aggregateDefinition->Save_W3D(save) == WW3D_ERROR_OK; + }), + qPrintable(exportError)); + + AggregateDefClass reloadedAggregate; + QVERIFY(loadSingleDefinition( + aggregateFirst, + W3D_CHUNK_AGGREGATE, + [&reloadedAggregate](ChunkLoadClass &load) { + return reloadedAggregate.Load_W3D(load) == WW3D_ERROR_OK; + })); + QCOMPARE(QString::fromLatin1(reloadedAggregate.Get_Name()), QString("c_ag_gdi_mgo")); + QCOMPARE(QString::fromLatin1(reloadedAggregate.Get_Base_Model_Name()), + QString("C_GDI_MGO_")); + QVERIFY2(saveAtomically( + aggregateSecond, + W3D_CHUNK_AGGREGATE, + [&reloadedAggregate](ChunkSaveClass &save) { + return reloadedAggregate.Save_W3D(save) == WW3D_ERROR_OK; + }), + qPrintable(exportError)); + + const QString soundFirst = QDir(outputDirectory.path()).filePath("sound-first.w3d"); + const QString soundSecond = QDir(outputDirectory.path()).filePath("sound-second.w3d"); + QVERIFY2(saveAtomically( + soundFirst, + W3D_CHUNK_SOUNDROBJ, + [soundDefinition](ChunkSaveClass &save) { + return soundDefinition->Save_W3D(save) == WW3D_ERROR_OK; + }), + qPrintable(exportError)); + + SoundRenderObjDefClass reloadedSound; + QVERIFY(loadSingleDefinition( + soundFirst, + W3D_CHUNK_SOUNDROBJ, + [&reloadedSound](ChunkLoadClass &load) { + return reloadedSound.Load_W3D(load) == WW3D_ERROR_OK; + })); + QCOMPARE(QString::fromLatin1(reloadedSound.Get_Name()), QString("A10_Loop")); + QVERIFY2(saveAtomically( + soundSecond, + W3D_CHUNK_SOUNDROBJ, + [&reloadedSound](ChunkSaveClass &save) { + return reloadedSound.Save_W3D(save) == WW3D_ERROR_OK; + }), + qPrintable(exportError)); + + const QString emitterFirst = QDir(outputDirectory.path()).filePath("emitter-first.w3d"); + const QString emitterSecond = QDir(outputDirectory.path()).filePath("emitter-second.w3d"); + QVERIFY2(saveAtomically( + emitterFirst, + W3D_CHUNK_EMITTER, + [emitterDefinition](ChunkSaveClass &save) { + return emitterDefinition->Save_W3D(save) == WW3D_ERROR_OK; + }), + qPrintable(exportError)); + ParticleEmitterDefClass reloadedEmitter; + QVERIFY(loadSingleDefinition( + emitterFirst, + W3D_CHUNK_EMITTER, + [&reloadedEmitter](ChunkLoadClass &load) { + return reloadedEmitter.Load_W3D(load) == WW3D_ERROR_OK; + })); + QCOMPARE(QString::fromLatin1(reloadedEmitter.Get_Name()), QString("e_flare02")); + QVERIFY2(saveAtomically( + emitterSecond, + W3D_CHUNK_EMITTER, + [&reloadedEmitter](ChunkSaveClass &save) { + return reloadedEmitter.Save_W3D(save) == WW3D_ERROR_OK; + }), + qPrintable(exportError)); + + const QString sphereFirst = QDir(outputDirectory.path()).filePath("sphere-first.w3d"); + const QString sphereSecond = QDir(outputDirectory.path()).filePath("sphere-second.w3d"); + QVERIFY2(saveAtomically( + sphereFirst, + W3D_CHUNK_SPHERE, + [spherePrototype](ChunkSaveClass &save) { + return spherePrototype->Save(save); + }), + qPrintable(exportError)); + SpherePrototypeClass reloadedSphere; + QVERIFY(loadSingleDefinition( + sphereFirst, + W3D_CHUNK_SPHERE, + [&reloadedSphere](ChunkLoadClass &load) { return reloadedSphere.Load(load); })); + QCOMPARE(QString::fromLatin1(reloadedSphere.Get_Name()), QString("XG_IonC_Shock0")); + QVERIFY2(saveAtomically( + sphereSecond, + W3D_CHUNK_SPHERE, + [&reloadedSphere](ChunkSaveClass &save) { return reloadedSphere.Save(save); }), + qPrintable(exportError)); + + const QString ringFirst = QDir(outputDirectory.path()).filePath("ring-first.w3d"); + const QString ringSecond = QDir(outputDirectory.path()).filePath("ring-second.w3d"); + QVERIFY2(saveAtomically( + ringFirst, + W3D_CHUNK_RING, + [ringPrototype](ChunkSaveClass &save) { return ringPrototype->Save(save); }), + qPrintable(exportError)); + RingPrototypeClass reloadedRing; + QVERIFY(loadSingleDefinition( + ringFirst, + W3D_CHUNK_RING, + [&reloadedRing](ChunkLoadClass &load) { return reloadedRing.Load(load); })); + QCOMPARE(QString::fromLatin1(reloadedRing.Get_Name()), QString("XG_IonC_Shock1")); + QVERIFY2(saveAtomically( + ringSecond, + W3D_CHUNK_RING, + [&reloadedRing](ChunkSaveClass &save) { return reloadedRing.Save(save); }), + qPrintable(exportError)); + + const QString lodFirst = QDir(outputDirectory.path()).filePath("lod-first.w3d"); + const QString lodSecond = QDir(outputDirectory.path()).filePath("lod-second.w3d"); + QVERIFY2(saveAtomically( + lodFirst, + W3D_CHUNK_HLOD, + [lodDefinition](ChunkSaveClass &save) { + return lodDefinition->Save(save) == WW3D_ERROR_OK; + }), + qPrintable(exportError)); + HLodDefClass reloadedLod; + QVERIFY(loadSingleDefinition( + lodFirst, + W3D_CHUNK_HLOD, + [&reloadedLod](ChunkLoadClass &load) { + return reloadedLod.Load_W3D(load) == WW3D_ERROR_OK; + })); + QCOMPARE(QString::fromLatin1(reloadedLod.Get_Name()), QString("C_NOD_SK_")); + QVERIFY2(saveAtomically( + lodSecond, + W3D_CHUNK_HLOD, + [&reloadedLod](ChunkSaveClass &save) { + return reloadedLod.Save(save) == WW3D_ERROR_OK; + }), + qPrintable(exportError)); + + QCOMPARE(fileBytes(aggregateSecond), fileBytes(aggregateFirst)); + QCOMPARE(fileBytes(soundSecond), fileBytes(soundFirst)); + + const struct { + QString source; + QString first; + QString second; + } byteStableExports[] = { + {QDir(assetDirectory).filePath("xg_ionc_shock0.w3d"), sphereFirst, sphereSecond}, + {QDir(assetDirectory).filePath("xg_ionc_shock1.w3d"), ringFirst, ringSecond}, + {QDir(assetDirectory).filePath("c_nod_sk_.w3d"), lodFirst, lodSecond}, + {QDir(assetDirectory).filePath("e_flare02.w3d"), emitterFirst, emitterSecond}, + }; + for (const auto &exportPaths : byteStableExports) { + const QByteArray sourceBytes = fileBytes(exportPaths.source); + const QByteArray firstBytes = fileBytes(exportPaths.first); + const QByteArray secondBytes = fileBytes(exportPaths.second); + QVERIFY2(!sourceBytes.isEmpty(), qPrintable(exportPaths.source)); + qsizetype firstDifference = -1; + qsizetype differenceCount = 0; + const qsizetype comparableSize = std::min(firstBytes.size(), sourceBytes.size()); + for (qsizetype index = 0; index < comparableSize; ++index) { + if (firstBytes.at(index) != sourceBytes.at(index)) { + if (firstDifference < 0) { + firstDifference = index; + } + ++differenceCount; + } + } + if (firstDifference < 0 && firstBytes.size() != sourceBytes.size()) { + firstDifference = comparableSize; + } + differenceCount += std::max(firstBytes.size(), sourceBytes.size()) - comparableSize; + const int sourceByte = firstDifference >= 0 && firstDifference < sourceBytes.size() + ? static_cast(sourceBytes.at(firstDifference)) + : -1; + const int firstByte = firstDifference >= 0 && firstDifference < firstBytes.size() + ? static_cast(firstBytes.at(firstDifference)) + : -1; + const QString difference = QStringLiteral( + "%1: source=%2 bytes, first=%3 bytes, differences=%4, " + "first difference=%5 (source=0x%6, first=0x%7)") + .arg(exportPaths.source) + .arg(sourceBytes.size()) + .arg(firstBytes.size()) + .arg(differenceCount) + .arg(firstDifference) + .arg(sourceByte, 2, 16, QLatin1Char('0')) + .arg(firstByte, 2, 16, QLatin1Char('0')); + QVERIFY2(secondBytes == firstBytes, qPrintable(exportPaths.source)); + QVERIFY2(firstBytes == sourceBytes, qPrintable(difference)); + } + + std::unique_ptr audio(WWAudioClass::Create_Instance()); + QVERIFY(audio != nullptr); + audio->Initialize(); + const bool openALBackend = + QString::fromLatin1(audio->Get_3D_Driver_Name().Peek_Buffer()) == + QStringLiteral("OpenAL 3D Audio"); + if (openALBackend) { + QVERIFY2(audio->Get_2D_Sample_Count() > 0, + "OpenAL did not create any 2D sources; ensure a playback device or " + "ALSOFT_DRIVERS=null is available"); + const QString streamingPath = + QDir(assetDirectory).filePath("elie_bounce_1.l.wav"); + QVERIFY2(QFileInfo::exists(streamingPath), + "The supplied large WAV needed for OpenAL 3D streaming is missing"); + QVERIFY2(QFileInfo(streamingPath).size() > DEF_MAX_3D_BUFFER_SIZE * 2, + "The supplied WAV does not cross OpenAL's 3D streaming threshold"); + std::unique_ptr> streamingSound( + audio->Create_3D_Sound( + QDir::toNativeSeparators(streamingPath).toLocal8Bit().constData(), + CLASSID_3D)); + QVERIFY2(streamingSound, "OpenAL could not create the supplied 3D streaming sound"); + streamingSound->Cull_Sound(false); + QVERIFY(streamingSound->Play()); + QVERIFY(streamingSound->Is_Playing()); + QVERIFY(streamingSound->Stop()); + QVERIFY(!streamingSound->Is_Playing()); + } + + // Exercise the actual MainWindow export actions and their fixed-filename + // Designer dialog. Reloading the sound source through the window refreshes + // the tree after its definition was loaded directly above. + QVERIFY2(_window->openFilePath(soundSource), + "Failed to refresh the tree with the real sound definition"); + QTreeView *treeView = _window->findChild("assetTreeView"); + auto *treeModel = treeView + ? qobject_cast(treeView->model()) + : nullptr; + QVERIFY(treeView); + QVERIFY(treeModel); + + QString commandExportFailure; + const auto exportThroughMainWindow = [&](const QString &groupPrefix, + const QString &assetName, + const char *actionName) { + commandExportFailure.clear(); + const QModelIndex group = findRootItem(groupPrefix); + const QModelIndex item = group.isValid() + ? findDirectChild(treeModel, group, assetName) + : QModelIndex(); + if (!item.isValid()) { + commandExportFailure = QString("Could not find %1 under %2") + .arg(assetName, groupPrefix); + return QString(); + } + + treeView->setCurrentIndex(item); + QCoreApplication::processEvents(); + QAction *exportAction = _window->findChild(actionName); + if (!exportAction || !exportAction->isEnabled()) { + commandExportFailure = QString("%1 was not enabled for %2") + .arg(QString::fromLatin1(actionName), assetName); + return QString(); + } + + const QString exactFilename = assetName + ".w3d"; + const QString expectedPath = + QDir(outputDirectory.path()).filePath(exactFilename); + bool dialogDriven = false; + QTimer dialogDriver; + dialogDriver.setSingleShot(true); + QObject::connect(&dialogDriver, &QTimer::timeout, _window.get(), [&]() { + auto *dialog = + qobject_cast(QApplication::activeModalWidget()); + if (!dialog) { + commandExportFailure = QString("The fixed export dialog did not open for %1") + .arg(assetName); + if (QWidget *modal = QApplication::activeModalWidget()) { + modal->close(); + } + return; + } + + QLineEdit *filenameEdit = dialog->findChild("filenameEdit"); + QLineEdit *directoryEdit = dialog->findChild("directoryEdit"); + if (!filenameEdit || !directoryEdit || !filenameEdit->isReadOnly() || + filenameEdit->text() != exactFilename) { + commandExportFailure = QString("The fixed filename was wrong for %1") + .arg(assetName); + dialog->reject(); + return; + } + + directoryEdit->setText(outputDirectory.path()); + if (QDir::cleanPath(dialog->selectedPath()) != QDir::cleanPath(expectedPath)) { + commandExportFailure = QString("The selected export path was wrong for %1") + .arg(assetName); + dialog->reject(); + return; + } + + dialogDriven = true; + dialog->accept(); + }); + dialogDriver.start(0); + exportAction->trigger(); + dialogDriver.stop(); + + if (!dialogDriven || !commandExportFailure.isEmpty()) { + return QString(); + } + if (!QFileInfo::exists(expectedPath)) { + commandExportFailure = QString("The command did not create %1").arg(expectedPath); + return QString(); + } + return expectedPath; + }; + + const QString commandAggregate = + exportThroughMainWindow("Aggregate", "c_ag_gdi_mgo", "actionExportAggregate"); + QVERIFY2(!commandAggregate.isEmpty(), qPrintable(commandExportFailure)); + AggregateDefClass commandAggregateDefinition; + QVERIFY(loadSingleDefinition( + commandAggregate, + W3D_CHUNK_AGGREGATE, + [&commandAggregateDefinition](ChunkLoadClass &load) { + return commandAggregateDefinition.Load_W3D(load) == WW3D_ERROR_OK; + })); + QCOMPARE(QString::fromLatin1(commandAggregateDefinition.Get_Name()), + QString("c_ag_gdi_mgo")); + + const QString commandEmitter = + exportThroughMainWindow("Emitter", "e_flare02", "actionExportEmitter"); + QVERIFY2(!commandEmitter.isEmpty(), qPrintable(commandExportFailure)); + ParticleEmitterDefClass commandEmitterDefinition; + QVERIFY(loadSingleDefinition( + commandEmitter, + W3D_CHUNK_EMITTER, + [&commandEmitterDefinition](ChunkLoadClass &load) { + return commandEmitterDefinition.Load_W3D(load) == WW3D_ERROR_OK; + })); + QCOMPARE(QString::fromLatin1(commandEmitterDefinition.Get_Name()), QString("e_flare02")); + + const QString commandLod = + exportThroughMainWindow("H-LOD", "C_NOD_SK_", "actionExportLod"); + QVERIFY2(!commandLod.isEmpty(), qPrintable(commandExportFailure)); + HLodDefClass commandLodDefinition; + QVERIFY(loadSingleDefinition( + commandLod, + W3D_CHUNK_HLOD, + [&commandLodDefinition](ChunkLoadClass &load) { + return commandLodDefinition.Load_W3D(load) == WW3D_ERROR_OK; + })); + QCOMPARE(QString::fromLatin1(commandLodDefinition.Get_Name()), QString("C_NOD_SK_")); + + const QString commandSphere = exportThroughMainWindow( + "Primitives", "XG_IonC_Shock0", "actionExportPrimitive"); + QVERIFY2(!commandSphere.isEmpty(), qPrintable(commandExportFailure)); + SpherePrototypeClass commandSphereDefinition; + QVERIFY(loadSingleDefinition( + commandSphere, + W3D_CHUNK_SPHERE, + [&commandSphereDefinition](ChunkLoadClass &load) { + return commandSphereDefinition.Load(load); + })); + QCOMPARE(QString::fromLatin1(commandSphereDefinition.Get_Name()), + QString("XG_IonC_Shock0")); + + const QString commandRing = exportThroughMainWindow( + "Primitives", "XG_IonC_Shock1", "actionExportPrimitive"); + QVERIFY2(!commandRing.isEmpty(), qPrintable(commandExportFailure)); + RingPrototypeClass commandRingDefinition; + QVERIFY(loadSingleDefinition( + commandRing, + W3D_CHUNK_RING, + [&commandRingDefinition](ChunkLoadClass &load) { + return commandRingDefinition.Load(load); + })); + QCOMPARE(QString::fromLatin1(commandRingDefinition.Get_Name()), + QString("XG_IonC_Shock1")); + + const QString commandSound = + exportThroughMainWindow("Sounds", "A10_Loop", "actionExportSoundObject"); + QVERIFY2(!commandSound.isEmpty(), qPrintable(commandExportFailure)); + SoundRenderObjDefClass commandSoundDefinition; + QVERIFY(loadSingleDefinition( + commandSound, + W3D_CHUNK_SOUNDROBJ, + [&commandSoundDefinition](ChunkLoadClass &load) { + return commandSoundDefinition.Load_W3D(load) == WW3D_ERROR_OK; + })); + QCOMPARE(QString::fromLatin1(commandSoundDefinition.Get_Name()), QString("A10_Loop")); + + // Repeating an export to an existing exact target must stop at one + // default-No overwrite prompt and leave the prior file untouched. + const QByteArray aggregateBeforeDecline = fileBytes(commandAggregate); + const QModelIndex aggregateGroup = findRootItem("Aggregate"); + const QModelIndex aggregateItem = aggregateGroup.isValid() + ? findDirectChild(treeModel, aggregateGroup, "c_ag_gdi_mgo") + : QModelIndex(); + QVERIFY(aggregateItem.isValid()); + treeView->setCurrentIndex(aggregateItem); + QCoreApplication::processEvents(); + QAction *aggregateExportAction = _window->findChild("actionExportAggregate"); + QVERIFY(aggregateExportAction); + QVERIFY(aggregateExportAction->isEnabled()); + + QString overwriteFailure; + bool overwriteDialogDriven = false; + bool overwritePromptDriven = false; + QTimer overwriteDialogDriver; + overwriteDialogDriver.setSingleShot(true); + QObject::connect(&overwriteDialogDriver, + &QTimer::timeout, + _window.get(), + [&]() { + auto *dialog = + qobject_cast(QApplication::activeModalWidget()); + if (!dialog) { + overwriteFailure = "The repeated export did not open ExportDirectoryDialog"; + if (QWidget *modal = QApplication::activeModalWidget()) { + modal->close(); + } + return; + } + QLineEdit *directoryEdit = dialog->findChild("directoryEdit"); + if (!directoryEdit) { + overwriteFailure = "The repeated export dialog had no directory field"; + dialog->reject(); + return; + } + directoryEdit->setText(outputDirectory.path()); + overwriteDialogDriven = true; + QTimer::singleShot(0, _window.get(), [&]() { + auto *warning = qobject_cast(QApplication::activeModalWidget()); + if (!warning) { + overwriteFailure = "The existing-target overwrite prompt did not open"; + if (QWidget *modal = QApplication::activeModalWidget()) { + modal->close(); + } + return; + } + if (warning->windowTitle() != "Export W3D" || + !warning->text().contains("already exists") || + warning->standardButton(warning->defaultButton()) != QMessageBox::No) { + overwriteFailure = "The existing-target prompt was not the expected default-No warning"; + warning->reject(); + return; + } + overwritePromptDriven = true; + warning->done(QMessageBox::No); + }); + dialog->accept(); + }); + overwriteDialogDriver.start(0); + aggregateExportAction->trigger(); + overwriteDialogDriver.stop(); + QVERIFY2(overwriteFailure.isEmpty(), qPrintable(overwriteFailure)); + QVERIFY(overwriteDialogDriven); + QVERIFY(overwritePromptDriven); + QCOMPARE(fileBytes(commandAggregate), aggregateBeforeDecline); + + { + std::unique_ptr> soundObject( + assetManager->Create_Render_Obj("A10_Loop")); + QVERIFY(soundObject); + QCOMPARE(soundObject->Class_ID(), static_cast(RenderObjClass::CLASSID_SOUND)); + + SoundEditDialog soundDialog(static_cast(soundObject.get())); + QLineEdit *nameEdit = soundDialog.findChild("nameEdit"); + QLineEdit *fileEdit = soundDialog.findChild("fileEdit"); + QCheckBox *infiniteLoops = soundDialog.findChild("infiniteLoops"); + QCheckBox *stopWhenHidden = soundDialog.findChild("stopWhenHidden"); + QRadioButton *radio3d = soundDialog.findChild("radio3d"); + QRadioButton *radioEffect = soundDialog.findChild("radioEffect"); + QSlider *volume = soundDialog.findChild("volumeSlider"); + QSlider *priority = soundDialog.findChild("prioritySlider"); + QDoubleSpinBox *dropOff = soundDialog.findChild("dropOffEdit"); + QDoubleSpinBox *maxVolume = soundDialog.findChild("maxVolEdit"); + QPushButton *playButton = soundDialog.findChild("playButton"); + QVERIFY(nameEdit); + QVERIFY(fileEdit); + QVERIFY(infiniteLoops); + QVERIFY(stopWhenHidden); + QVERIFY(radio3d); + QVERIFY(radioEffect); + QVERIFY(volume); + QVERIFY(priority); + QVERIFY(dropOff); + QVERIFY(maxVolume); + QVERIFY(playButton); + QCOMPARE(nameEdit->text(), QString("A10_Loop")); + QCOMPARE(fileEdit->text(), QString("aircraft_jet_a10_loop_1.wav")); + QVERIFY(infiniteLoops->isChecked()); + QVERIFY(stopWhenHidden->isChecked()); + QVERIFY(radio3d->isChecked()); + QVERIFY(radioEffect->isChecked()); + QCOMPARE(volume->value(), 100); + QCOMPARE(priority->value(), 100); + QCOMPARE(dropOff->value(), 200.0); + QCOMPARE(maxVolume->value(), 20.0); + + const QString soundPreviewPath = + QDir(assetDirectory).filePath("aircraft_jet_a10_loop_1.wav"); + QVERIFY(QFileInfo::exists(soundPreviewPath)); + fileEdit->setText(QDir::toNativeSeparators(soundPreviewPath)); + + QString playbackFailure; + bool playbackDialogDriven = false; + bool unavailablePreviewHandled = false; + QTimer::singleShot(0, &soundDialog, + [&playbackFailure, + &playbackDialogDriven, + &unavailablePreviewHandled]() { + QWidget *activeModal = QApplication::activeModalWidget(); + if (auto *warning = qobject_cast(activeModal)) { + if (warning->windowTitle() != "Play Sound") { + playbackFailure = "An unexpected warning replaced the Play Sound dialog"; + } else { + unavailablePreviewHandled = true; + } + warning->accept(); + return; + } + + auto *playDialog = qobject_cast(activeModal); + if (!playDialog || playDialog->objectName() != "PlaySoundDialog") { + playbackFailure = "The Play Sound dialog did not become active"; + if (playDialog) { + playDialog->reject(); + } + return; + } + + QPushButton *stop = playDialog->findChild("stopButton"); + QPushButton *play = playDialog->findChild("playButton"); + if (!stop || !play) { + playbackFailure = "The Play/Stop controls were not found"; + playDialog->reject(); + return; + } + + stop->click(); + play->click(); + stop->click(); + playbackDialogDriven = true; + playDialog->reject(); + }); + playButton->click(); + QVERIFY2(playbackFailure.isEmpty(), qPrintable(playbackFailure)); + if (openALBackend) { + QVERIFY2(playbackDialogDriven, + "The OpenAL build could not preview the supplied real sound asset"); + QVERIFY(!unavailablePreviewHandled); + } else { + QVERIFY(playbackDialogDriven || unavailablePreviewHandled); + } + QVERIFY(!QApplication::activeModalWidget()); + soundDialog.reject(); + } + if (W3DViewport *viewport = _window->findChild("viewport")) { + viewport->setRenderObject(nullptr); + } + audio.reset(); + QVERIFY(WWAudioClass::Get_Instance() == nullptr); +} + +int main(int argc, char **argv) +{ + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) { + qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen")); + } + + QApplication application(argc, argv); + WWMath::Init(); + int result = 0; + { + WW3DAssetManager assetManager; + assetManager.Set_WW3D_Load_On_Demand(true); + MainWindowCommandTests tests; + result = QTest::qExec(&tests, argc, argv); + } + WWMath::Shutdown(); + return result; +} + +#include "MainWindowCommandTests.moc" diff --git a/Code/Tools/W3DViewQt/tests/PrimitiveShaderDialogTests.cpp b/Code/Tools/W3DViewQt/tests/PrimitiveShaderDialogTests.cpp new file mode 100644 index 000000000..09da72912 --- /dev/null +++ b/Code/Tools/W3DViewQt/tests/PrimitiveShaderDialogTests.cpp @@ -0,0 +1,388 @@ +#include "RingEditDialog.h" +#include "RenderObjUtils.h" +#include "SphereEditDialog.h" + +#include "assetmgr.h" +#include "chunkio.h" +#include "ramfile.h" +#include "ringobj.h" +#include "shader.h" +#include "sphereobj.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { +template +struct ReleaseRef { + void operator()(T *object) const + { + if (object) { + object->Release_Ref(); + } + } +}; + +template +using RefPtr = std::unique_ptr>; + +class FailOnceRAMFile final : public RAMFileClass +{ +public: + FailOnceRAMFile(void *buffer, int length, int failedWrite) + : RAMFileClass(buffer, length), failedWrite_(failedWrite) + { + } + + int Write(const void *buffer, int size) override + { + ++writeCount_; + if (writeCount_ == failedWrite_) { + return 0; + } + return RAMFileClass::Write(buffer, size); + } + + int writeCount() const { return writeCount_; } + +private: + int failedWrite_ = 0; + int writeCount_ = 0; +}; + +ShaderClass customShader() +{ + ShaderClass shader = ShaderClass::_PresetAdditiveShader; + shader.Set_Fog_Func(ShaderClass::FOG_ENABLE); + return shader; +} + +QComboBox *shaderCombo(QDialog &dialog) +{ + auto *combo = dialog.findChild("shaderCombo"); + if (!combo) { + QTest::qFail("shaderCombo was not created from the Designer form", __FILE__, __LINE__); + } + return combo; +} + +void acceptDialog(QDialog &dialog) +{ + auto *buttonBox = dialog.findChild("buttonBox"); + if (!buttonBox) { + QTest::qFail("buttonBox was not created from the Designer form", __FILE__, __LINE__); + return; + } + + auto *okButton = buttonBox->button(QDialogButtonBox::Ok); + if (!okButton) { + QTest::qFail("buttonBox has no OK button", __FILE__, __LINE__); + return; + } + + okButton->click(); + QCOMPARE(dialog.result(), static_cast(QDialog::Accepted)); +} + +QPushButton *dialogButton(QDialog &dialog, QDialogButtonBox::StandardButton button) +{ + auto *buttonBox = dialog.findChild("buttonBox"); + if (!buttonBox) { + QTest::qFail("buttonBox was not created from the Designer form", __FILE__, __LINE__); + return nullptr; + } + + auto *result = buttonBox->button(button); + if (!result) { + QTest::qFail("requested standard button is missing", __FILE__, __LINE__); + } + return result; +} + +template +void selectPreset(Dialog &dialog, const QString &label) +{ + QComboBox *combo = shaderCombo(dialog); + QVERIFY(combo); + const int index = combo->findText(label); + QVERIFY2(index >= 0, qPrintable(QString("Shader preset '%1' was not found").arg(label))); + combo->setCurrentIndex(index); +} +} + +class PrimitiveShaderDialogTests final : public QObject +{ + Q_OBJECT + +private slots: + void spherePreservesCustomShaderOnAccept(); + void sphereAppliesExplicitKnownPreset(); + void ringPreservesCustomShaderOnAccept(); + void ringAppliesExplicitKnownPreset(); + void sphereCancelRestoresLastAppliedPreview(); + void ringCleanOkDoesNotApplyTwice(); + void prototypeNameCollisionIsNonDestructive(); + void primitiveSerializersReportWriteFailure(); +}; + +void PrimitiveShaderDialogTests::spherePreservesCustomShaderOnAccept() +{ + RefPtr sphere(new SphereRenderObjClass); + sphere->Set_Name("CustomSphere"); + ShaderClass original = customShader(); + sphere->Set_Shader(original); + + SphereEditDialog dialog(sphere.get()); + QComboBox *combo = shaderCombo(dialog); + QVERIFY(combo); + QCOMPARE(combo->currentText(), QString("Custom (preserved)")); + QCOMPARE(combo->currentData().toInt(), -1); + + acceptDialog(dialog); + QCOMPARE(sphere->Get_Shader().Get_Bits(), original.Get_Bits()); +} + +void PrimitiveShaderDialogTests::sphereAppliesExplicitKnownPreset() +{ + RefPtr sphere(new SphereRenderObjClass); + sphere->Set_Name("PresetSphere"); + ShaderClass original = customShader(); + sphere->Set_Shader(original); + + SphereEditDialog dialog(sphere.get()); + selectPreset(dialog, "Opaque"); + acceptDialog(dialog); + + QCOMPARE(sphere->Get_Shader().Get_Bits(), ShaderClass::_PresetOpaqueShader.Get_Bits()); +} + +void PrimitiveShaderDialogTests::ringPreservesCustomShaderOnAccept() +{ + RefPtr ring(new RingRenderObjClass); + ring->Set_Name("CustomRing"); + ShaderClass original = customShader(); + ring->Set_Shader(original); + + RingEditDialog dialog(ring.get()); + QComboBox *combo = shaderCombo(dialog); + QVERIFY(combo); + QCOMPARE(combo->currentText(), QString("Custom (preserved)")); + QCOMPARE(combo->currentData().toInt(), -1); + + acceptDialog(dialog); + QCOMPARE(ring->Get_Shader().Get_Bits(), original.Get_Bits()); +} + +void PrimitiveShaderDialogTests::ringAppliesExplicitKnownPreset() +{ + RefPtr ring(new RingRenderObjClass); + ring->Set_Name("PresetRing"); + ShaderClass original = customShader(); + ring->Set_Shader(original); + + RingEditDialog dialog(ring.get()); + selectPreset(dialog, "Opaque"); + acceptDialog(dialog); + + QCOMPARE(ring->Get_Shader().Get_Bits(), ShaderClass::_PresetOpaqueShader.Get_Bits()); +} + +void PrimitiveShaderDialogTests::sphereCancelRestoresLastAppliedPreview() +{ + RefPtr sphere(new SphereRenderObjClass); + sphere->Set_Name("OriginalSphere"); + sphere->Set_Animation_Duration(1.0f); + + SphereEditDialog dialog(sphere.get()); + int applyCount = 0; + QString appliedFrom; + dialog.setApplyHandler( + [&](SphereRenderObjClass &, const QString ®isteredName) { + ++applyCount; + appliedFrom = registeredName; + return true; + }, + "OriginalSphere"); + + auto *nameEdit = dialog.findChild("nameEdit"); + auto *lifetimeSpin = dialog.findChild("lifetimeSpin"); + QVERIFY(nameEdit); + QVERIFY(lifetimeSpin); + nameEdit->setText("AppliedSphere"); + lifetimeSpin->setValue(2.0); + QCOMPARE(QString::fromLatin1(sphere->Get_Name()), QString("AppliedSphere")); + QCOMPARE(sphere->Get_Animation_Duration(), 2.0f); + + QPushButton *applyButton = dialogButton(dialog, QDialogButtonBox::Apply); + QVERIFY(applyButton); + QVERIFY(applyButton->isEnabled()); + applyButton->click(); + QCOMPARE(applyCount, 1); + QCOMPARE(appliedFrom, QString("OriginalSphere")); + QCOMPARE(dialog.registeredName(), QString("AppliedSphere")); + QVERIFY(!applyButton->isEnabled()); + + lifetimeSpin->setValue(3.0); + QCOMPARE(sphere->Get_Animation_Duration(), 3.0f); + QPushButton *cancelButton = dialogButton(dialog, QDialogButtonBox::Cancel); + QVERIFY(cancelButton); + cancelButton->click(); + QCOMPARE(dialog.result(), static_cast(QDialog::Rejected)); + QCOMPARE(QString::fromLatin1(sphere->Get_Name()), QString("AppliedSphere")); + QCOMPARE(sphere->Get_Animation_Duration(), 2.0f); + QCOMPARE(applyCount, 1); +} + +void PrimitiveShaderDialogTests::ringCleanOkDoesNotApplyTwice() +{ + RefPtr ring(new RingRenderObjClass); + ring->Set_Name("OriginalRing"); + ring->Set_Texture_Tiling(1); + + RingEditDialog dialog(ring.get()); + int applyCount = 0; + QStringList registeredNames; + dialog.setApplyHandler( + [&](RingRenderObjClass &, const QString ®isteredName) { + ++applyCount; + registeredNames.push_back(registeredName); + return true; + }, + "OriginalRing"); + + auto *nameEdit = dialog.findChild("nameEdit"); + auto *tilingSpin = dialog.findChild("tilingSpin"); + QVERIFY(nameEdit); + QVERIFY(tilingSpin); + nameEdit->setText("AppliedRing"); + tilingSpin->setValue(2); + QCOMPARE(QString::fromLatin1(ring->Get_Name()), QString("AppliedRing")); + QCOMPARE(ring->Get_Texture_Tiling(), 2); + + QPushButton *applyButton = dialogButton(dialog, QDialogButtonBox::Apply); + QVERIFY(applyButton); + applyButton->click(); + QCOMPARE(applyCount, 1); + QCOMPARE(registeredNames, QStringList{"OriginalRing"}); + QVERIFY(!applyButton->isEnabled()); + + acceptDialog(dialog); + QCOMPARE(applyCount, 1); + QCOMPARE(dialog.registeredName(), QString("AppliedRing")); +} + +void PrimitiveShaderDialogTests::prototypeNameCollisionIsNonDestructive() +{ + WW3DAssetManager assetManager; + RefPtr source(new SphereRenderObjClass); + source->Set_Name("SourceSphere"); + RefPtr destination(new RingRenderObjClass); + destination->Set_Name("TakenName"); + + QString errorMessage; + QVERIFY(UpdateSpherePrototype(*source, QString(), &errorMessage)); + QVERIFY(UpdateRingPrototype(*destination, QString(), &errorMessage)); + auto *sourcePrototype = assetManager.Find_Prototype("SourceSphere"); + auto *destinationPrototype = assetManager.Find_Prototype("TakenName"); + QVERIFY(sourcePrototype); + QVERIFY(destinationPrototype); + + source->Set_Name("TakenName"); + QVERIFY(!UpdateSpherePrototype(*source, "SourceSphere", &errorMessage)); + QVERIFY(errorMessage.contains("already exists")); + QCOMPARE(assetManager.Find_Prototype("SourceSphere"), sourcePrototype); + QCOMPARE(assetManager.Find_Prototype("TakenName"), destinationPrototype); +} + +void PrimitiveShaderDialogTests::primitiveSerializersReportWriteFailure() +{ + // One chunk header fits, but the first nested definition header does not. + // This reproduces a short write without relying on a full disk. + std::array sphereStorage = {}; + RAMFileClass sphereFile(sphereStorage.data(), static_cast(sphereStorage.size())); + QVERIFY(sphereFile.Open(FileClass::WRITE)); + ChunkSaveClass sphereSave(&sphereFile); + SpherePrototypeClass spherePrototype; + QVERIFY(!spherePrototype.Save(sphereSave)); + QCOMPARE(sphereSave.Cur_Chunk_Depth(), 0); + sphereFile.Close(); + + std::array ringStorage = {}; + RAMFileClass ringFile(ringStorage.data(), static_cast(ringStorage.size())); + QVERIFY(ringFile.Open(FileClass::WRITE)); + ChunkSaveClass ringSave(&ringFile); + RingPrototypeClass ringPrototype; + QVERIFY(!ringPrototype.Save(ringSave)); + QCOMPARE(ringSave.Cur_Chunk_Depth(), 0); + ringFile.Close(); + + SphereRenderObjClass animatedSphere; + animatedSphere.Set_Name("AnimatedSphere"); + animatedSphere.Get_Color_Channel().Add_Key(Vector3(0.25f, 0.5f, 0.75f), 0.0f); + SpherePrototypeClass animatedSpherePrototype(&animatedSphere); + + std::array countingSphereStorage = {}; + FailOnceRAMFile countingSphereFile( + countingSphereStorage.data(), static_cast(countingSphereStorage.size()), 0); + QVERIFY(countingSphereFile.Open(FileClass::WRITE)); + ChunkSaveClass countingSphereSave(&countingSphereFile); + QVERIFY(animatedSpherePrototype.Save(countingSphereSave)); + const int sphereWriteCount = countingSphereFile.writeCount(); + QVERIFY(sphereWriteCount > 0); + countingSphereFile.Close(); + + for (int failedWrite = 1; failedWrite <= sphereWriteCount; ++failedWrite) { + std::array storage = {}; + FailOnceRAMFile file(storage.data(), static_cast(storage.size()), failedWrite); + QVERIFY(file.Open(FileClass::WRITE)); + ChunkSaveClass save(&file); + QVERIFY2(!animatedSpherePrototype.Save(save), + qPrintable(QString("Sphere serializer ignored failed write %1 of %2") + .arg(failedWrite) + .arg(sphereWriteCount))); + QCOMPARE(save.Cur_Chunk_Depth(), 0); + QVERIFY(save.Has_Write_Error()); + file.Close(); + } + + RingRenderObjClass animatedRing; + animatedRing.Set_Name("AnimatedRing"); + animatedRing.Get_Color_Channel().Add_Key(Vector3(0.75f, 0.5f, 0.25f), 0.0f); + RingPrototypeClass animatedRingPrototype(&animatedRing); + + std::array countingRingStorage = {}; + FailOnceRAMFile countingRingFile( + countingRingStorage.data(), static_cast(countingRingStorage.size()), 0); + QVERIFY(countingRingFile.Open(FileClass::WRITE)); + ChunkSaveClass countingRingSave(&countingRingFile); + QVERIFY(animatedRingPrototype.Save(countingRingSave)); + const int ringWriteCount = countingRingFile.writeCount(); + QVERIFY(ringWriteCount > 0); + countingRingFile.Close(); + + for (int failedWrite = 1; failedWrite <= ringWriteCount; ++failedWrite) { + std::array storage = {}; + FailOnceRAMFile file(storage.data(), static_cast(storage.size()), failedWrite); + QVERIFY(file.Open(FileClass::WRITE)); + ChunkSaveClass save(&file); + QVERIFY2(!animatedRingPrototype.Save(save), + qPrintable(QString("Ring serializer ignored failed write %1 of %2") + .arg(failedWrite) + .arg(ringWriteCount))); + QCOMPARE(save.Cur_Chunk_Depth(), 0); + QVERIFY(save.Has_Write_Error()); + file.Close(); + } +} + +QTEST_MAIN(PrimitiveShaderDialogTests) + +#include "PrimitiveShaderDialogTests.moc" diff --git a/Code/Tools/W3DViewQt/tests/ResolutionDialogTests.cpp b/Code/Tools/W3DViewQt/tests/ResolutionDialogTests.cpp new file mode 100644 index 000000000..209c4606b --- /dev/null +++ b/Code/Tools/W3DViewQt/tests/ResolutionDialogTests.cpp @@ -0,0 +1,115 @@ +#include "ResolutionDialog.h" + +#include +#include +#include +#include +#include + +namespace { +QTableWidget *resolutionTable(ResolutionDialog &dialog) +{ + auto *table = dialog.findChild("resolutionTable"); + if (!table) { + QTest::qFail("resolutionTable was not created from the Designer form", __FILE__, __LINE__); + } + return table; +} +} // namespace + +class ResolutionDialogTests final : public QObject +{ + Q_OBJECT + +private slots: + void ordersDeduplicatesAndSelectsStoredPreference(); + void fallsBackToCurrentModeAndUsesLiveWindowState(); + void doubleClickSelectsModeAndAccepts(); +}; + +void ResolutionDialogTests::ordersDeduplicatesAndSelectsStoredPreference() +{ + const QVector modes = { + {1920, 1080, 32}, + {800, 600, 16}, + {1280, 720, 32}, + {1920, 1080, 32}, + {0, 768, 32}, + {1024, 768, 32}, + {800, 600, 32}, + }; + ResolutionDialog dialog(modes, + ResolutionDialog::Mode(1280, 720, 32), + ResolutionDialog::Mode(1920, 1080, 32), + true); + + QTableWidget *table = resolutionTable(dialog); + QVERIFY(table); + QCOMPARE(table->rowCount(), 4); + QCOMPARE(table->item(0, 0)->text(), QString("800 x 600")); + QCOMPARE(table->item(1, 0)->text(), QString("1024 x 768")); + QCOMPARE(table->item(2, 0)->text(), QString("1280 x 720")); + QCOMPARE(table->item(3, 0)->text(), QString("1920 x 1080")); + for (int row = 0; row < table->rowCount(); ++row) { + QVERIFY(table->item(row, 1)->text().startsWith("32 bpp")); + } + + QCOMPARE(dialog.selectedWidth(), 1920); + QCOMPARE(dialog.selectedHeight(), 1080); + QCOMPARE(dialog.selectedBitsPerPixel(), 32); + + auto *fullscreen = dialog.findChild("fullscreenCheck"); + QVERIFY(fullscreen); + QVERIFY(fullscreen->isChecked()); + QCOMPARE(fullscreen->text(), QString("&Borderless fullscreen")); + + auto *hint = dialog.findChild("hintLabel"); + QVERIFY(hint); + QVERIFY(hint->text().contains("viewport follows the window size")); +} + +void ResolutionDialogTests::fallsBackToCurrentModeAndUsesLiveWindowState() +{ + const QVector modes = { + {1920, 1080, 32}, + {1280, 720, 32}, + }; + ResolutionDialog dialog(modes, + ResolutionDialog::Mode(1280, 720, 32), + ResolutionDialog::Mode(1600, 900, 32), + false); + + QCOMPARE(dialog.selectedWidth(), 1280); + QCOMPARE(dialog.selectedHeight(), 720); + QCOMPARE(dialog.selectedBitsPerPixel(), 32); + + auto *fullscreen = dialog.findChild("fullscreenCheck"); + QVERIFY(fullscreen); + QVERIFY(!fullscreen->isChecked()); +} + +void ResolutionDialogTests::doubleClickSelectsModeAndAccepts() +{ + const QVector modes = { + {800, 600, 32}, + {1920, 1080, 32}, + }; + ResolutionDialog dialog(modes, + ResolutionDialog::Mode(800, 600, 32), + ResolutionDialog::Mode(800, 600, 32), + false); + + QVERIFY(QMetaObject::invokeMethod(&dialog, + "onDoubleClicked", + Qt::DirectConnection, + Q_ARG(int, 1), + Q_ARG(int, 0))); + QCOMPARE(dialog.result(), int(QDialog::Accepted)); + QCOMPARE(dialog.selectedWidth(), 1920); + QCOMPARE(dialog.selectedHeight(), 1080); + QCOMPARE(dialog.selectedBitsPerPixel(), 32); +} + +QTEST_MAIN(ResolutionDialogTests) + +#include "ResolutionDialogTests.moc" diff --git a/Code/Tools/W3DViewQt/tests/SceneLightTests.cpp b/Code/Tools/W3DViewQt/tests/SceneLightTests.cpp new file mode 100644 index 000000000..c19ade176 --- /dev/null +++ b/Code/Tools/W3DViewQt/tests/SceneLightTests.cpp @@ -0,0 +1,307 @@ +#include "SceneLightDialog.h" +#include "W3DViewport.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { +constexpr float kFloatTolerance = 0.0001f; + +bool fuzzyEqual(float actual, float expected) +{ + return std::fabs(actual - expected) <= kFloatTolerance; +} + +bool fuzzyEqual(const Vector3 &actual, const Vector3 &expected) +{ + return fuzzyEqual(actual.X, expected.X) && fuzzyEqual(actual.Y, expected.Y) + && fuzzyEqual(actual.Z, expected.Z); +} + +bool fuzzyEqual(const Quaternion &actual, const Quaternion &expected) +{ + return fuzzyEqual(actual.X, expected.X) && fuzzyEqual(actual.Y, expected.Y) + && fuzzyEqual(actual.Z, expected.Z) && fuzzyEqual(actual.W, expected.W); +} + +void compareState(const W3DViewport::SceneLightState &actual, + const W3DViewport::SceneLightState &expected) +{ + QVERIFY2(fuzzyEqual(actual.diffuse, expected.diffuse), "Diffuse color did not round-trip"); + QVERIFY2(fuzzyEqual(actual.specular, expected.specular), "Specular color did not round-trip"); + QVERIFY2(fuzzyEqual(actual.orientation, expected.orientation), + "Orientation did not round-trip"); + QVERIFY2(fuzzyEqual(actual.distance, expected.distance), "Distance did not round-trip"); + QVERIFY2(fuzzyEqual(actual.intensity, expected.intensity), "Intensity did not round-trip"); + QVERIFY2(fuzzyEqual(actual.attenuationStart, expected.attenuationStart), + "Attenuation start did not round-trip"); + QVERIFY2(fuzzyEqual(actual.attenuationEnd, expected.attenuationEnd), + "Attenuation end did not round-trip"); + QCOMPARE(actual.attenuationEnabled, expected.attenuationEnabled); + QCOMPARE(actual.orientationExplicit, expected.orientationExplicit); + QCOMPARE(actual.distanceExplicit, expected.distanceExplicit); +} +} // namespace + +class SceneLightTests final : public QObject +{ + Q_OBJECT + +private slots: + void stateRoundTripPreservesIndependentChannelsAndFlags(); + void cancelRestoresCompleteInitialState(); + void channelSelectionUpdatesOnlySelectedChannel(); + void grayscaleHonorsChannelSelection(); + void designerFormExposesRequiredControlsAndRanges(); +}; + +void SceneLightTests::stateRoundTripPreservesIndependentChannelsAndFlags() +{ + W3DViewport viewport; + + W3DViewport::SceneLightState first; + first.diffuse = Vector3(0.25f, 0.50f, 0.75f); + first.specular = Vector3(0.75f, 0.25f, 0.50f); + first.orientation = Quaternion(0.10f, 0.20f, 0.30f, 0.90f); + first.distance = 125.5f; + first.intensity = 0.65f; + first.attenuationStart = 15.25f; + first.attenuationEnd = 240.75f; + first.attenuationEnabled = true; + first.orientationExplicit = true; + first.distanceExplicit = false; + + viewport.setSceneLightState(first); + compareState(viewport.sceneLightState(), first); + + W3DViewport::SceneLightState second = first; + second.diffuse = Vector3(0.60f, 0.40f, 0.20f); + second.specular = Vector3(0.10f, 0.30f, 0.90f); + second.orientation = Quaternion(-0.20f, 0.30f, -0.10f, 0.90f); + second.distance = 42.0f; + second.attenuationEnabled = false; + second.orientationExplicit = false; + second.distanceExplicit = true; + + viewport.setSceneLightState(second); + compareState(viewport.sceneLightState(), second); +} + +void SceneLightTests::cancelRestoresCompleteInitialState() +{ + W3DViewport viewport; + W3DViewport::SceneLightState initial; + initial.diffuse = Vector3(0.25f, 0.50f, 0.75f); + initial.specular = Vector3(0.75f, 0.50f, 0.25f); + initial.orientation = Quaternion(0.15f, -0.25f, 0.05f, 0.95f); + initial.distance = 12.5f; + initial.intensity = 0.60f; + initial.attenuationStart = 7.5f; + initial.attenuationEnd = 80.0f; + initial.attenuationEnabled = false; + initial.orientationExplicit = false; + initial.distanceExplicit = false; + viewport.setSceneLightState(initial); + + SceneLightDialog dialog(viewport); + auto *redSlider = dialog.findChild("redSlider"); + auto *greenSlider = dialog.findChild("greenSlider"); + auto *blueSlider = dialog.findChild("blueSlider"); + auto *specularButton = dialog.findChild("specularRadioButton"); + auto *intensitySlider = dialog.findChild("intensitySlider"); + auto *distanceSpinBox = dialog.findChild("distanceSpinBox"); + auto *attenuationGroupBox = dialog.findChild("attenuationGroupBox"); + auto *attenuationStartSpinBox = + dialog.findChild("attenuationStartSpinBox"); + auto *attenuationEndSpinBox = dialog.findChild("attenuationEndSpinBox"); + + QVERIFY(redSlider); + QVERIFY(greenSlider); + QVERIFY(blueSlider); + QVERIFY(specularButton); + QVERIFY(intensitySlider); + QVERIFY(distanceSpinBox); + QVERIFY(attenuationGroupBox); + QVERIFY(attenuationStartSpinBox); + QVERIFY(attenuationEndSpinBox); + + redSlider->setValue(90); + greenSlider->setValue(80); + blueSlider->setValue(70); + specularButton->setChecked(true); + redSlider->setValue(10); + greenSlider->setValue(20); + blueSlider->setValue(30); + intensitySlider->setValue(35); + distanceSpinBox->setValue(456.75); + attenuationGroupBox->setChecked(true); + attenuationStartSpinBox->setValue(100.0); + attenuationEndSpinBox->setValue(900.0); + + // A viewport drag can update orientation while this modeless editing state is live. + viewport.setSceneLightOrientation(Quaternion(-0.30f, 0.20f, 0.10f, 0.90f)); + const W3DViewport::SceneLightState edited = viewport.sceneLightState(); + QVERIFY(edited.orientationExplicit); + QVERIFY(edited.distanceExplicit); + QVERIFY(!fuzzyEqual(edited.diffuse, initial.diffuse)); + QVERIFY(!fuzzyEqual(edited.specular, initial.specular)); + + dialog.reject(); + + compareState(viewport.sceneLightState(), initial); +} + +void SceneLightTests::channelSelectionUpdatesOnlySelectedChannel() +{ + W3DViewport viewport; + W3DViewport::SceneLightState initial; + initial.diffuse = Vector3(0.25f, 0.50f, 0.75f); + initial.specular = Vector3(0.75f, 0.50f, 0.25f); + viewport.setSceneLightState(initial); + + SceneLightDialog dialog(viewport); + auto *redSlider = dialog.findChild("redSlider"); + auto *greenSlider = dialog.findChild("greenSlider"); + auto *diffuseButton = dialog.findChild("diffuseRadioButton"); + auto *specularButton = dialog.findChild("specularRadioButton"); + QVERIFY(redSlider); + QVERIFY(greenSlider); + QVERIFY(diffuseButton); + QVERIFY(specularButton); + QVERIFY(diffuseButton->isChecked()); + + redSlider->setValue(40); + QVERIFY(fuzzyEqual(viewport.sceneLightDiffuse(), Vector3(0.40f, 0.50f, 0.75f))); + QVERIFY(fuzzyEqual(viewport.sceneLightSpecular(), initial.specular)); + + const Vector3 diffuseAfterEdit = viewport.sceneLightDiffuse(); + specularButton->setChecked(true); + greenSlider->setValue(60); + QVERIFY(fuzzyEqual(viewport.sceneLightDiffuse(), diffuseAfterEdit)); + QVERIFY(fuzzyEqual(viewport.sceneLightSpecular(), Vector3(0.75f, 0.60f, 0.25f))); +} + +void SceneLightTests::grayscaleHonorsChannelSelection() +{ + W3DViewport viewport; + W3DViewport::SceneLightState initial; + initial.diffuse = Vector3(0.25f, 0.50f, 0.75f); + initial.specular = Vector3(0.75f, 0.50f, 0.25f); + viewport.setSceneLightState(initial); + + SceneLightDialog dialog(viewport); + auto *redSlider = dialog.findChild("redSlider"); + auto *grayscaleCheckBox = dialog.findChild("grayscaleCheckBox"); + auto *specularButton = dialog.findChild("specularRadioButton"); + auto *bothButton = dialog.findChild("bothRadioButton"); + QVERIFY(redSlider); + QVERIFY(grayscaleCheckBox); + QVERIFY(specularButton); + QVERIFY(bothButton); + QVERIFY(!grayscaleCheckBox->isChecked()); + + grayscaleCheckBox->setChecked(true); + QVERIFY(fuzzyEqual(viewport.sceneLightDiffuse(), Vector3(0.25f, 0.25f, 0.25f))); + QVERIFY(fuzzyEqual(viewport.sceneLightSpecular(), initial.specular)); + + const Vector3 diffuseGrayscale = viewport.sceneLightDiffuse(); + grayscaleCheckBox->setChecked(false); + specularButton->setChecked(true); + grayscaleCheckBox->setChecked(true); + QVERIFY(fuzzyEqual(viewport.sceneLightDiffuse(), diffuseGrayscale)); + QVERIFY(fuzzyEqual(viewport.sceneLightSpecular(), Vector3(0.75f, 0.75f, 0.75f))); + + grayscaleCheckBox->setChecked(false); + bothButton->setChecked(true); + redSlider->setValue(40); + grayscaleCheckBox->setChecked(true); + QVERIFY(fuzzyEqual(viewport.sceneLightDiffuse(), Vector3(0.40f, 0.40f, 0.40f))); + QVERIFY(fuzzyEqual(viewport.sceneLightSpecular(), Vector3(0.40f, 0.40f, 0.40f))); +} + +void SceneLightTests::designerFormExposesRequiredControlsAndRanges() +{ + W3DViewport viewport; + SceneLightDialog dialog(viewport); + + const char *requiredObjects[] = { + "channelGroupBox", + "diffuseRadioButton", + "specularRadioButton", + "bothRadioButton", + "redSlider", + "greenSlider", + "blueSlider", + "grayscaleCheckBox", + "intensitySlider", + "distanceSpinBox", + "attenuationGroupBox", + "attenuationStartLabel", + "attenuationStartSpinBox", + "attenuationEndLabel", + "attenuationEndSpinBox", + "repositionHintLabel", + "buttonBox", + }; + + for (const char *objectName : requiredObjects) { + QVERIFY2(dialog.findChild(objectName), objectName); + } + + const char *colorSliderNames[] = {"redSlider", "greenSlider", "blueSlider"}; + for (const char *objectName : colorSliderNames) { + auto *slider = dialog.findChild(objectName); + QVERIFY(slider); + QCOMPARE(slider->minimum(), 0); + QCOMPARE(slider->maximum(), 100); + } + + auto *intensitySlider = dialog.findChild("intensitySlider"); + QVERIFY(intensitySlider); + QCOMPARE(intensitySlider->minimum(), 0); + QCOMPARE(intensitySlider->maximum(), 100); + + const char *spinBoxNames[] = { + "distanceSpinBox", + "attenuationStartSpinBox", + "attenuationEndSpinBox", + }; + for (const char *objectName : spinBoxNames) { + auto *spinBox = dialog.findChild(objectName); + QVERIFY(spinBox); + QCOMPARE(spinBox->minimum(), 0.0); + QCOMPARE(spinBox->maximum(), 1000000.0); + QCOMPARE(spinBox->decimals(), 2); + QVERIFY(std::fabs(spinBox->singleStep() - 0.01) <= 0.000001); + } + + auto *attenuationGroupBox = dialog.findChild("attenuationGroupBox"); + QVERIFY(attenuationGroupBox); + QVERIFY(attenuationGroupBox->isCheckable()); + + auto *buttonBox = dialog.findChild("buttonBox"); + QVERIFY(buttonBox); + QVERIFY(buttonBox->standardButtons().testFlag(QDialogButtonBox::Ok)); + QVERIFY(buttonBox->standardButtons().testFlag(QDialogButtonBox::Cancel)); +} + +int main(int argc, char **argv) +{ + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) { + qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen")); + } + + QApplication application(argc, argv); + SceneLightTests tests; + return QTest::qExec(&tests, argc, argv); +} + +#include "SceneLightTests.moc" diff --git a/Code/Tools/W3DViewQt/tests/SettingsSaveMaskTests.cpp b/Code/Tools/W3DViewQt/tests/SettingsSaveMaskTests.cpp new file mode 100644 index 000000000..76accba7d --- /dev/null +++ b/Code/Tools/W3DViewQt/tests/SettingsSaveMaskTests.cpp @@ -0,0 +1,324 @@ +#include "MainWindow.h" +#include "SaveSettingsDialog.h" +#include "W3DViewport.h" + +#include "assetmgr.h" +#include "wwmath.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { +constexpr float kTolerance = 0.0001f; + +struct LightingState { + Vector3 ambient; + Vector3 diffuse; + Vector3 specular; + Quaternion orientation; + float distance = 0.0f; + float intensity = 0.0f; + float attenuationStart = 0.0f; + float attenuationEnd = 0.0f; + bool attenuationEnabled = false; +}; + +struct BackgroundState { + Vector3 color; + QString bitmap; + bool fogEnabled = false; +}; + +bool fuzzyEqual(float actual, float expected) +{ + return std::fabs(actual - expected) <= kTolerance; +} + +bool fuzzyEqual(const Vector3 &actual, const Vector3 &expected) +{ + return fuzzyEqual(actual.X, expected.X) && fuzzyEqual(actual.Y, expected.Y) + && fuzzyEqual(actual.Z, expected.Z); +} + +bool fuzzyEqual(const Quaternion &actual, const Quaternion &expected) +{ + return fuzzyEqual(actual.X, expected.X) && fuzzyEqual(actual.Y, expected.Y) + && fuzzyEqual(actual.Z, expected.Z) && fuzzyEqual(actual.W, expected.W); +} + +void setLighting(W3DViewport &viewport, const LightingState &state) +{ + viewport.setAmbientLight(state.ambient); + viewport.setSceneLightDiffuse(state.diffuse); + viewport.setSceneLightSpecular(state.specular); + viewport.setSceneLightOrientation(state.orientation); + viewport.setSceneLightDistance(state.distance); + viewport.setSceneLightIntensity(state.intensity); + viewport.setSceneLightAttenuation( + state.attenuationStart, state.attenuationEnd, state.attenuationEnabled); +} + +void compareLighting(const W3DViewport &viewport, const LightingState &expected) +{ + QVERIFY(fuzzyEqual(viewport.ambientLight(), expected.ambient)); + QVERIFY(fuzzyEqual(viewport.sceneLightDiffuse(), expected.diffuse)); + QVERIFY(fuzzyEqual(viewport.sceneLightSpecular(), expected.specular)); + QVERIFY(fuzzyEqual(viewport.sceneLightOrientation(), expected.orientation)); + QVERIFY(fuzzyEqual(viewport.sceneLightDistance(), expected.distance)); + QVERIFY(fuzzyEqual(viewport.sceneLightIntensity(), expected.intensity)); + + float attenuationStart = 0.0f; + float attenuationEnd = 0.0f; + bool attenuationEnabled = false; + viewport.sceneLightAttenuation( + attenuationStart, attenuationEnd, attenuationEnabled); + QVERIFY(fuzzyEqual(attenuationStart, expected.attenuationStart)); + QVERIFY(fuzzyEqual(attenuationEnd, expected.attenuationEnd)); + QCOMPARE(attenuationEnabled, expected.attenuationEnabled); +} + +void setBackground(W3DViewport &viewport, const BackgroundState &state) +{ + viewport.setBackgroundColor(state.color); + viewport.setBackgroundBitmap(state.bitmap); + viewport.setFogEnabled(state.fogEnabled); +} + +void compareBackground(const W3DViewport &viewport, const BackgroundState &expected) +{ + QVERIFY(fuzzyEqual(viewport.backgroundColor(), expected.color)); + QCOMPARE(viewport.backgroundBitmap(), expected.bitmap); + QCOMPARE(viewport.isFogEnabled(), expected.fogEnabled); +} + +void writeLighting(QSettings &settings, const LightingState &state) +{ + settings.setValue("AmbientLightR", state.ambient.X); + settings.setValue("AmbientLightG", state.ambient.Y); + settings.setValue("AmbientLightB", state.ambient.Z); + settings.setValue("SceneLightR", state.diffuse.X); + settings.setValue("SceneLightG", state.diffuse.Y); + settings.setValue("SceneLightB", state.diffuse.Z); + settings.setValue("SceneLightDiffuseR", state.diffuse.X); + settings.setValue("SceneLightDiffuseG", state.diffuse.Y); + settings.setValue("SceneLightDiffuseB", state.diffuse.Z); + settings.setValue("SceneLightSpecularR", state.specular.X); + settings.setValue("SceneLightSpecularG", state.specular.Y); + settings.setValue("SceneLightSpecularB", state.specular.Z); + settings.setValue("SceneLightX", state.orientation.X); + settings.setValue("SceneLightY", state.orientation.Y); + settings.setValue("SceneLightZ", state.orientation.Z); + settings.setValue("SceneLightW", state.orientation.W); + settings.setValue("SceneLightDistance", state.distance); + settings.setValue("SceneLightIntensity", state.intensity); + settings.setValue("SceneLightAttenStart", state.attenuationStart); + settings.setValue("SceneLightAttenEnd", state.attenuationEnd); + settings.setValue("SceneLightAttenOn", state.attenuationEnabled ? 1 : 0); +} + +void writeBackground(QSettings &settings, const BackgroundState &state) +{ + settings.setValue("BackgroundR", state.color.X); + settings.setValue("BackgroundG", state.color.Y); + settings.setValue("BackgroundB", state.color.Z); + settings.setValue("BackgroundBMP", state.bitmap); + settings.setValue("FogEnabled", state.fogEnabled); +} + +bool saveThroughDialog(W3DViewMainWindow &window, + const QString &path, + bool saveLighting, + bool saveBackground, + QString &failure) +{ + QAction *saveAction = window.findChild("actionSaveSettings"); + if (!saveAction) { + failure = "The Save Settings action was not found"; + return false; + } + + bool interacted = false; + QTimer::singleShot(0, &window, [&]() { + auto *dialog = qobject_cast(QApplication::activeModalWidget()); + if (!dialog) { + failure = "The Save Settings dialog did not become active"; + if (QWidget *modal = QApplication::activeModalWidget()) { + modal->close(); + } + return; + } + + QLineEdit *pathEdit = dialog->findChild("pathLineEdit"); + QCheckBox *lighting = dialog->findChild("lightingCheckBox"); + QCheckBox *background = dialog->findChild("backgroundCheckBox"); + if (!pathEdit || !lighting || !background) { + failure = "The Save Settings dialog controls were not found"; + dialog->reject(); + return; + } + + pathEdit->setText(path); + lighting->setChecked(saveLighting); + background->setChecked(saveBackground); + interacted = true; + dialog->accept(); + }); + + saveAction->trigger(); + return interacted && failure.isEmpty(); +} +} // namespace + +class SettingsSaveMaskTests final : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase(); + void backgroundOnlyPreservesAndLoadsExistingLightingKeys(); + void lightingOnlyPreservesAndLoadsExistingBackgroundKeys(); + +private: + QTemporaryDir _settingsDirectory; +}; + +void SettingsSaveMaskTests::initTestCase() +{ + QVERIFY2(_settingsDirectory.isValid(), "Could not create an isolated settings directory"); + QSettings::setDefaultFormat(QSettings::IniFormat); + QSettings::setPath( + QSettings::IniFormat, QSettings::UserScope, _settingsDirectory.path()); + QCoreApplication::setOrganizationName("OpenW3DTests"); + QCoreApplication::setApplicationName("W3DViewQtSettingsSaveMaskTests"); +} + +void SettingsSaveMaskTests::backgroundOnlyPreservesAndLoadsExistingLightingKeys() +{ + const LightingState preservedLighting{ + Vector3(0.11f, 0.22f, 0.33f), + Vector3(0.44f, 0.55f, 0.66f), + Vector3(0.77f, 0.88f, 0.99f), + Quaternion(0.10f, 0.20f, 0.30f, 0.90f), + 123.0f, + 0.65f, + 12.0f, + 456.0f, + true}; + const BackgroundState savedBackground{ + Vector3(0.15f, 0.35f, 0.55f), QStringLiteral("saved-background.tga"), true}; + const LightingState unsavedLighting{ + Vector3(0.91f, 0.82f, 0.73f), + Vector3(0.64f, 0.55f, 0.46f), + Vector3(0.37f, 0.28f, 0.19f), + Quaternion(-0.10f, 0.15f, -0.20f, 0.95f), + 987.0f, + 0.25f, + 98.0f, + 765.0f, + false}; + + const QString path = QDir(_settingsDirectory.path()).filePath("background-only.dat"); + { + QSettings settings(path, QSettings::IniFormat); + settings.beginGroup("Settings"); + writeLighting(settings, preservedLighting); + settings.endGroup(); + settings.sync(); + QCOMPARE(settings.status(), QSettings::NoError); + } + + W3DViewMainWindow window; + W3DViewport *viewport = window.findChild("viewport"); + QVERIFY(viewport); + setLighting(*viewport, unsavedLighting); + setBackground(*viewport, savedBackground); + + QString failure; + QVERIFY2(saveThroughDialog(window, path, false, true, failure), qPrintable(failure)); + + setLighting(*viewport, unsavedLighting); + setBackground(*viewport, BackgroundState{Vector3(0.9f, 0.8f, 0.7f), "changed.tga", false}); + QVERIFY(window.loadSettingsPath(path)); + compareLighting(*viewport, preservedLighting); + compareBackground(*viewport, savedBackground); +} + +void SettingsSaveMaskTests::lightingOnlyPreservesAndLoadsExistingBackgroundKeys() +{ + const BackgroundState preservedBackground{ + Vector3(0.12f, 0.34f, 0.56f), QStringLiteral("preserved-background.dds"), true}; + const LightingState savedLighting{ + Vector3(0.13f, 0.24f, 0.35f), + Vector3(0.46f, 0.57f, 0.68f), + Vector3(0.79f, 0.81f, 0.92f), + Quaternion(0.15f, -0.25f, 0.05f, 0.95f), + 321.0f, + 0.75f, + 21.0f, + 654.0f, + true}; + + const QString path = QDir(_settingsDirectory.path()).filePath("lighting-only.dat"); + { + QSettings settings(path, QSettings::IniFormat); + settings.beginGroup("Settings"); + writeBackground(settings, preservedBackground); + settings.endGroup(); + settings.sync(); + QCOMPARE(settings.status(), QSettings::NoError); + } + + W3DViewMainWindow window; + W3DViewport *viewport = window.findChild("viewport"); + QVERIFY(viewport); + setLighting(*viewport, savedLighting); + setBackground(*viewport, BackgroundState{Vector3(0.9f, 0.7f, 0.5f), "unsaved.tga", false}); + + QString failure; + QVERIFY2(saveThroughDialog(window, path, true, false, failure), qPrintable(failure)); + + setLighting(*viewport, + LightingState{Vector3(0.9f, 0.8f, 0.7f), + Vector3(0.6f, 0.5f, 0.4f), + Vector3(0.3f, 0.2f, 0.1f), + Quaternion(-0.1f, 0.2f, -0.3f, 0.9f), + 999.0f, + 0.1f, + 99.0f, + 999.0f, + false}); + setBackground(*viewport, BackgroundState{Vector3(0.8f, 0.6f, 0.4f), "changed.dds", false}); + QVERIFY(window.loadSettingsPath(path)); + compareLighting(*viewport, savedLighting); + compareBackground(*viewport, preservedBackground); +} + +int main(int argc, char **argv) +{ + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) { + qputenv("QT_QPA_PLATFORM", QByteArrayLiteral("offscreen")); + } + + QApplication application(argc, argv); + WWMath::Init(); + int result = 0; + { + WW3DAssetManager assetManager; + assetManager.Set_WW3D_Load_On_Demand(true); + SettingsSaveMaskTests tests; + result = QTest::qExec(&tests, argc, argv); + } + WWMath::Shutdown(); + return result; +} + +#include "SettingsSaveMaskTests.moc" diff --git a/Code/Tools/W3DViewQt/tests/SoundDialogTests.cpp b/Code/Tools/W3DViewQt/tests/SoundDialogTests.cpp new file mode 100644 index 000000000..154cfbdbb --- /dev/null +++ b/Code/Tools/W3DViewQt/tests/SoundDialogTests.cpp @@ -0,0 +1,203 @@ +#include "PlaySoundDialog.h" +#include "SoundEditDialog.h" + +#include "AudibleSound.h" +#include "WWAudio.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { +QByteArray makeMonoPcmWav() +{ + constexpr quint32 sampleRate = 8000; + constexpr quint16 channelCount = 1; + constexpr quint16 bitsPerSample = 8; + constexpr int sampleCount = 8000; + + QByteArray samples(sampleCount, '\0'); + for (int index = 0; index < samples.size(); ++index) { + // Unsigned 8-bit square wave with a modest amplitude. + samples[index] = static_cast(((index / 20) % 2) ? 160 : 96); + } + + QByteArray wav; + QDataStream stream(&wav, QIODevice::WriteOnly); + stream.setByteOrder(QDataStream::LittleEndian); + stream.writeRawData("RIFF", 4); + stream << quint32(36 + samples.size()); + stream.writeRawData("WAVE", 4); + stream.writeRawData("fmt ", 4); + stream << quint32(16); + stream << quint16(1); // PCM + stream << channelCount; + stream << sampleRate; + stream << quint32(sampleRate * channelCount * bitsPerSample / 8); + stream << quint16(channelCount * bitsPerSample / 8); + stream << bitsPerSample; + stream.writeRawData("data", 4); + stream << quint32(samples.size()); + stream.writeRawData(samples.constData(), samples.size()); + return wav; +} + +bool isOpenALBackend(const WWAudioClass &audio) +{ + return QString::fromLatin1(audio.Get_3D_Driver_Name().Peek_Buffer()) == + QStringLiteral("OpenAL 3D Audio"); +} + +void closeActiveMessageBox() +{ + if (auto *message = qobject_cast(QApplication::activeModalWidget())) { + message->accept(); + } +} +} // namespace + +class SoundDialogTests final : public QObject +{ + Q_OBJECT + +private slots: + void nameLimitMatchesW3dFormat(); + void runtimeNameLimitRejectsOversizedName(); + void failedPreviewDoesNotOpenPlaybackDialog(); + void successfulPreviewPlaysAndStopsWithOpenAL(); +}; + +void SoundDialogTests::nameLimitMatchesW3dFormat() +{ + SoundEditDialog dialog(nullptr); + auto *nameEdit = dialog.findChild("nameEdit"); + QVERIFY(nameEdit); + QCOMPARE(nameEdit->maxLength(), 15); +} + +void SoundDialogTests::runtimeNameLimitRejectsOversizedName() +{ + SoundEditDialog dialog(nullptr); + auto *nameEdit = dialog.findChild("nameEdit"); + auto *buttonBox = dialog.findChild("buttonBox"); + QVERIFY(nameEdit); + QVERIFY(buttonBox); + + // Exercise the runtime guard independently of the Designer constraint. + nameEdit->setMaxLength(64); + nameEdit->setText("sixteen_characters"); + + bool warningClosed = false; + QTimer::singleShot(0, &dialog, [&warningClosed]() { + warningClosed = qobject_cast(QApplication::activeModalWidget()) != nullptr; + closeActiveMessageBox(); + }); + buttonBox->button(QDialogButtonBox::Ok)->click(); + + QVERIFY(warningClosed); + QVERIFY(dialog.result() != QDialog::Accepted); +} + +void SoundDialogTests::failedPreviewDoesNotOpenPlaybackDialog() +{ + SoundEditDialog dialog(nullptr); + auto *fileEdit = dialog.findChild("fileEdit"); + auto *playButton = dialog.findChild("playButton"); + QVERIFY(fileEdit); + QVERIFY(playButton); + fileEdit->setText("missing-preview.wav"); + + bool warningClosed = false; + bool playbackDialogShown = false; + QTimer modalMonitor; + connect(&modalMonitor, &QTimer::timeout, &dialog, [&]() { + if (auto *message = qobject_cast(QApplication::activeModalWidget())) { + warningClosed = true; + message->accept(); + return; + } + + for (QWidget *widget : QApplication::topLevelWidgets()) { + if (widget->objectName() == "PlaySoundDialog" && widget->isVisible()) { + playbackDialogShown = true; + qobject_cast(widget)->reject(); + } + } + }); + modalMonitor.start(0); + playButton->click(); + modalMonitor.stop(); + + QVERIFY(warningClosed); + QVERIFY(!playbackDialogShown); +} + +void SoundDialogTests::successfulPreviewPlaysAndStopsWithOpenAL() +{ + QTemporaryDir fixtureDirectory; + QVERIFY(fixtureDirectory.isValid()); + + const QString soundPath = fixtureDirectory.filePath("preview.wav"); + QFile soundFile(soundPath); + QVERIFY(soundFile.open(QIODevice::WriteOnly)); + const QByteArray wav = makeMonoPcmWav(); + QCOMPARE(soundFile.write(wav), static_cast(wav.size())); + soundFile.close(); + + std::unique_ptr audio(WWAudioClass::Create_Instance()); + QVERIFY(audio != nullptr); + audio->Initialize(); + if (!isOpenALBackend(*audio)) { + QSKIP("Successful preview playback requires the OpenAL backend"); + } + QVERIFY2(audio->Get_2D_Sample_Count() > 0, + "OpenAL did not create any 2D sources; ensure a playback device or " + "ALSOFT_DRIVERS=null is available"); + + { + PlaySoundDialog dialog(soundPath); + QVERIFY(dialog.isReady()); + + auto *playButton = dialog.findChild("playButton"); + auto *stopButton = dialog.findChild("stopButton"); + QVERIFY(playButton); + QVERIFY(stopButton); + + const auto activePreviewSound = [&audio]() -> AudibleSoundClass * { + for (int index = 0; index < audio->Get_2D_Sample_Count(); ++index) { + if (AudibleSoundClass *sound = audio->Peek_2D_Sample(index)) { + return sound; + } + } + return nullptr; + }; + + AudibleSoundClass *previewSound = activePreviewSound(); + QVERIFY2(previewSound, "The ready preview did not acquire an OpenAL source"); + QVERIFY(previewSound->Is_Playing()); + + stopButton->click(); + QVERIFY(!previewSound->Is_Playing()); + playButton->click(); + QCOMPARE(activePreviewSound(), previewSound); + QVERIFY(previewSound->Is_Playing()); + stopButton->click(); + QVERIFY(!previewSound->Is_Playing()); + } + + audio.reset(); + QVERIFY(WWAudioClass::Get_Instance() == nullptr); +} + +QTEST_MAIN(SoundDialogTests) + +#include "SoundDialogTests.moc" diff --git a/Code/Tools/W3DViewQt/tests/VerifyDesignerForms.cmake b/Code/Tools/W3DViewQt/tests/VerifyDesignerForms.cmake new file mode 100644 index 000000000..4fd3f5718 --- /dev/null +++ b/Code/Tools/W3DViewQt/tests/VerifyDesignerForms.cmake @@ -0,0 +1,113 @@ +if(NOT DEFINED W3DVIEW_QT_SOURCE_DIR OR NOT IS_DIRECTORY "${W3DVIEW_QT_SOURCE_DIR}") + message(FATAL_ERROR "W3DVIEW_QT_SOURCE_DIR must name the W3DViewQt source directory") +endif() +if(NOT DEFINED W3DVIEW_QT_BINARY_DIR) + message(FATAL_ERROR "W3DVIEW_QT_BINARY_DIR is required") +endif() +if(NOT DEFINED QT_UIC_EXECUTABLE OR NOT EXISTS "${QT_UIC_EXECUTABLE}") + message(FATAL_ERROR "QT_UIC_EXECUTABLE does not exist: ${QT_UIC_EXECUTABLE}") +endif() + +set(expected_forms + AddToLineupDialog.ui + AdvancedAnimationDialog.ui + AggregateNameDialog.ui + AnimatedSoundOptionsDialog.ui + AnimationPropertiesDialog.ui + AnimationSettingsDialog.ui + BackgroundBitmapDialog.ui + BackgroundObjectDialog.ui + BoneManagementDialog.ui + CameraDistanceDialog.ui + CameraSettingsDialog.ui + ColorLightDialog.ui + EmitterEditDialog.ui + ExportDirectoryDialog.ui + GammaDialog.ui + HierarchyPropertiesDialog.ui + MainWindow.ui + MeshPropertiesDialog.ui + OpacityVectorEditDialog.ui + PlaySoundDialog.ui + ResolutionDialog.ui + RingEditDialog.ui + ScaleDialog.ui + SaveSettingsDialog.ui + SceneLightDialog.ui + SoundEditDialog.ui + SphereEditDialog.ui + TexturePathDialog.ui +) +list(SORT expected_forms) + +file(GLOB actual_forms RELATIVE "${W3DVIEW_QT_SOURCE_DIR}" "${W3DVIEW_QT_SOURCE_DIR}/*.ui") +list(SORT actual_forms) +if(NOT actual_forms STREQUAL expected_forms) + message(FATAL_ERROR + "Designer form set differs from the required one.\nExpected: ${expected_forms}\nActual: ${actual_forms}") +endif() + +file(READ "${W3DVIEW_QT_SOURCE_DIR}/CMakeLists.txt" source_manifest) +file(MAKE_DIRECTORY "${W3DVIEW_QT_BINARY_DIR}") +foreach(form IN LISTS expected_forms) + string(FIND "${source_manifest}" " ${form}" manifest_index) + if(manifest_index EQUAL -1) + message(FATAL_ERROR "${form} is not listed in the W3DViewQt CMake source manifest") + endif() + + get_filename_component(form_name "${form}" NAME_WE) + execute_process( + COMMAND "${QT_UIC_EXECUTABLE}" + -o "${W3DVIEW_QT_BINARY_DIR}/ui_${form_name}.h" + "${W3DVIEW_QT_SOURCE_DIR}/${form}" + RESULT_VARIABLE uic_result + OUTPUT_VARIABLE uic_output + ERROR_VARIABLE uic_error + ) + if(NOT uic_result EQUAL 0) + message(FATAL_ERROR "uic failed for ${form}:\n${uic_output}\n${uic_error}") + endif() +endforeach() + +file(GLOB dialog_sources "${W3DVIEW_QT_SOURCE_DIR}/*Dialog.cpp") +list(APPEND dialog_sources "${W3DVIEW_QT_SOURCE_DIR}/MainWindow.cpp") +foreach(source IN LISTS dialog_sources) + file(READ "${source}" source_text) + if(source_text MATCHES "new[ \t\r\n]+Q(VBox|HBox|Grid|Form|Stacked)Layout") + message(FATAL_ERROR "Runtime layout construction remains in ${source}") + endif() +endforeach() + +file(READ "${W3DVIEW_QT_SOURCE_DIR}/MainWindow.ui" main_window_form) +foreach(required_object IN ITEMS + menuBar MainToolbar ObjectToolbar AnimationToolbar mainSplitter assetTreeView viewport + permanentStatusPanel statusPolysLabel statusParticlesLabel statusCameraLabel + statusFramesLabel statusFpsLabel statusResolutionLabel actionChangeResolution) + string(FIND "${main_window_form}" "name=\"${required_object}\"" object_index) + if(object_index EQUAL -1) + message(FATAL_ERROR "MainWindow.ui is missing ${required_object}") + endif() +endforeach() + +file(READ "${W3DVIEW_QT_SOURCE_DIR}/EmitterEditDialog.ui" emitter_form) +string(REGEX MATCHALL "" emitter_tab_titles "${emitter_form}") +list(LENGTH emitter_tab_titles emitter_tab_count) +if(NOT emitter_tab_count EQUAL 10) + message(FATAL_ERROR "EmitterEditDialog.ui must contain exactly 10 property tabs; found ${emitter_tab_count}") +endif() +foreach(tab_title IN ITEMS General Particle Physics Color Size User Line Rotation Frame "Line Group") + string(FIND "${emitter_form}" "${tab_title}" tab_index) + if(tab_index EQUAL -1) + message(FATAL_ERROR "EmitterEditDialog.ui is missing the ${tab_title} tab") + endif() +endforeach() + +file(READ "${W3DVIEW_QT_SOURCE_DIR}/W3DViewQt.qrc" resource_manifest) +foreach(resource_alias IN ITEMS app.ico main-toolbar.bmp play.bmp pause.bmp stop.bmp reverse.bmp ffwd.bmp) + string(FIND "${resource_manifest}" "alias=\"${resource_alias}\"" resource_index) + if(resource_index EQUAL -1) + message(FATAL_ERROR "W3DViewQt.qrc is missing ${resource_alias}") + endif() +endforeach() + +message(STATUS "Validated ${emitter_tab_count} emitter tabs and all 28 Designer forms") diff --git a/Code/Tools/W3DViewQt/tests/W3DExportUtilsTests.cpp b/Code/Tools/W3DViewQt/tests/W3DExportUtilsTests.cpp new file mode 100644 index 000000000..1ed99755b --- /dev/null +++ b/Code/Tools/W3DViewQt/tests/W3DExportUtilsTests.cpp @@ -0,0 +1,241 @@ +#include "W3DExportUtils.h" + +#include "chunkio.h" +#include "ramfile.h" +#include "rawfile.h" + +#include +#include +#include +#include +#include + +#include + +namespace +{ +constexpr std::uint32_t ExpectedChunk = 0x13572468U; +constexpr std::uint32_t WrongChunk = 0x24681357U; + +bool WriteBytes(const QString &path, const QByteArray &bytes) +{ + QFile file(path); + if (!file.open(QIODevice::WriteOnly)) { + return false; + } + return file.write(bytes) == bytes.size(); +} + +QByteArray ReadBytes(const QString &path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return {}; + } + return file.readAll(); +} + +bool WriteSingleChunk(ChunkSaveClass &chunk_save, + std::uint32_t chunk_id, + const QByteArray &payload) +{ + if (!chunk_save.Begin_Chunk(chunk_id)) { + return false; + } + + const bool payload_written = + chunk_save.Write(payload.constData(), static_cast(payload.size())) == + static_cast(payload.size()); + const bool chunk_closed = chunk_save.End_Chunk(); + return payload_written && chunk_closed; +} + +QStringList DirectoryEntries(const QString &path) +{ + return QDir(path).entryList(QDir::AllEntries | QDir::Hidden | QDir::System | + QDir::NoDotAndDotDot, + QDir::Name); +} +} + +class W3DExportUtilsTests : public QObject +{ + Q_OBJECT + +private slots: + void failedWriterPreservesExistingFile(); + void unbalancedWriterPreservesExistingFile(); + void wrongTopLevelChunkPreservesExistingFile(); + void successfulExportAtomicallyReplacesAndParses(); + void nonexistentParentFailsWithoutCreatingAnything(); + void chunkWriterRetainsShortWriteFailure(); +}; + +void W3DExportUtilsTests::failedWriterPreservesExistingFile() +{ + QTemporaryDir temporary_directory; + QVERIFY(temporary_directory.isValid()); + + const QString target = QDir(temporary_directory.path()).filePath("asset.w3d"); + const QByteArray sentinel("original-sentinel"); + QVERIFY(WriteBytes(target, sentinel)); + + bool serialization_succeeded = false; + QString error_message; + const bool saved = W3DExportUtils::SaveChunkFileAtomically( + target, + ExpectedChunk, + [&](ChunkSaveClass &chunk_save) { + serialization_succeeded = + WriteSingleChunk(chunk_save, ExpectedChunk, QByteArray("replacement")); + return false; + }, + &error_message); + + QVERIFY(serialization_succeeded); + QVERIFY(!saved); + QVERIFY(error_message.contains("writer", Qt::CaseInsensitive)); + QCOMPARE(ReadBytes(target), sentinel); + QCOMPARE(DirectoryEntries(temporary_directory.path()), QStringList{"asset.w3d"}); +} + +void W3DExportUtilsTests::unbalancedWriterPreservesExistingFile() +{ + QTemporaryDir temporary_directory; + QVERIFY(temporary_directory.isValid()); + + const QString target = QDir(temporary_directory.path()).filePath("asset.w3d"); + const QByteArray sentinel("original-sentinel"); + QVERIFY(WriteBytes(target, sentinel)); + + QString error_message; + const bool saved = W3DExportUtils::SaveChunkFileAtomically( + target, + ExpectedChunk, + [](ChunkSaveClass &chunk_save) { return chunk_save.Begin_Chunk(ExpectedChunk); }, + &error_message); + + QVERIFY(!saved); + QVERIFY(error_message.contains("unbalanced", Qt::CaseInsensitive)); + QCOMPARE(ReadBytes(target), sentinel); + QCOMPARE(DirectoryEntries(temporary_directory.path()), QStringList{"asset.w3d"}); +} + +void W3DExportUtilsTests::wrongTopLevelChunkPreservesExistingFile() +{ + QTemporaryDir temporary_directory; + QVERIFY(temporary_directory.isValid()); + + const QString target = QDir(temporary_directory.path()).filePath("asset.w3d"); + const QByteArray sentinel("original-sentinel"); + QVERIFY(WriteBytes(target, sentinel)); + + QString error_message; + const bool saved = W3DExportUtils::SaveChunkFileAtomically( + target, + ExpectedChunk, + [](ChunkSaveClass &chunk_save) { + return WriteSingleChunk(chunk_save, WrongChunk, QByteArray("wrong chunk")); + }, + &error_message); + + QVERIFY(!saved); + QVERIFY(error_message.contains("top-level chunk", Qt::CaseInsensitive)); + QCOMPARE(ReadBytes(target), sentinel); + QCOMPARE(DirectoryEntries(temporary_directory.path()), QStringList{"asset.w3d"}); +} + +void W3DExportUtilsTests::successfulExportAtomicallyReplacesAndParses() +{ + QTemporaryDir temporary_directory; + QVERIFY(temporary_directory.isValid()); + + const QString target = QDir(temporary_directory.path()).filePath("asset.w3d"); + QVERIFY(WriteBytes(target, QByteArray("original-sentinel"))); + const QByteArray payload("replacement-payload"); + + QString error_message("stale error"); + const bool saved = W3DExportUtils::SaveChunkFileAtomically( + target, + ExpectedChunk, + [&](ChunkSaveClass &chunk_save) { + return WriteSingleChunk(chunk_save, ExpectedChunk, payload); + }, + &error_message); + + QVERIFY2(saved, qPrintable(error_message)); + QVERIFY(error_message.isEmpty()); + + const QByteArray native_target = + QFile::encodeName(QDir::toNativeSeparators(QFileInfo(target).absoluteFilePath())); + RawFileClass file(native_target.constData()); + QVERIFY(file.Open(FileClass::READ)); + const int file_size = file.Size(); + + ChunkLoadClass chunk_load(&file); + QVERIFY(chunk_load.Open_Chunk()); + QCOMPARE(static_cast(chunk_load.Cur_Chunk_ID()), ExpectedChunk); + QCOMPARE(static_cast(chunk_load.Cur_Chunk_Length()), + static_cast(payload.size())); + + QByteArray parsed_payload(payload.size(), '\0'); + QCOMPARE(static_cast( + chunk_load.Read(parsed_payload.data(), static_cast(parsed_payload.size()))), + static_cast(payload.size())); + QCOMPARE(parsed_payload, payload); + QVERIFY(chunk_load.Close_Chunk()); + QCOMPARE(file.Tell(), file_size); + file.Close(); + + QCOMPARE(DirectoryEntries(temporary_directory.path()), QStringList{"asset.w3d"}); +} + +void W3DExportUtilsTests::nonexistentParentFailsWithoutCreatingAnything() +{ + QTemporaryDir temporary_directory; + QVERIFY(temporary_directory.isValid()); + + const QString missing_directory = + QDir(temporary_directory.path()).filePath("missing-parent"); + const QString target = QDir(missing_directory).filePath("asset.w3d"); + + QString error_message; + const bool saved = W3DExportUtils::SaveChunkFileAtomically( + target, + ExpectedChunk, + [](ChunkSaveClass &chunk_save) { + return WriteSingleChunk(chunk_save, ExpectedChunk, QByteArray("payload")); + }, + &error_message); + + QVERIFY(!saved); + QVERIFY(error_message.contains("directory", Qt::CaseInsensitive)); + QVERIFY(!QFileInfo::exists(target)); + QVERIFY(!QFileInfo::exists(missing_directory)); + QVERIFY(DirectoryEntries(temporary_directory.path()).isEmpty()); +} + +void W3DExportUtilsTests::chunkWriterRetainsShortWriteFailure() +{ + std::array storage{}; + RAMFileClass file(storage.data(), static_cast(storage.size())); + QVERIFY(file.Open(FileClass::WRITE)); + + ChunkSaveClass chunkSave(&file); + QVERIFY(chunkSave.Begin_Chunk(ExpectedChunk)); + + const std::array payload{}; + QCOMPARE(chunkSave.Write(payload.data(), payload.size()), std::uint32_t{0}); + QVERIFY(chunkSave.Has_Write_Error()); + + // Rewriting the top header still fits, but a successful structural close must + // not erase the earlier short-write failure. + QVERIFY(!chunkSave.End_Chunk()); + QCOMPARE(chunkSave.Cur_Chunk_Depth(), 0); + QVERIFY(chunkSave.Has_Write_Error()); + file.Close(); +} + +QTEST_APPLESS_MAIN(W3DExportUtilsTests) + +#include "W3DExportUtilsTests.moc" diff --git a/Code/Tools/W3DViewQt/tests/W3DViewportFogTests.cpp b/Code/Tools/W3DViewQt/tests/W3DViewportFogTests.cpp new file mode 100644 index 000000000..0f280a60a --- /dev/null +++ b/Code/Tools/W3DViewQt/tests/W3DViewportFogTests.cpp @@ -0,0 +1,389 @@ +#include "W3DViewport.h" + +#include "assetmgr.h" +#include "ffactory.h" +#include "sphereobj.h" +#include "wwmath.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif + +namespace { +class FileFactorySearchPathGuard final +{ +public: + FileFactorySearchPathGuard() + { + if (_TheSimpleFileFactory) { + _TheSimpleFileFactory->Get_Sub_Directory(_original); + } + } + + ~FileFactorySearchPathGuard() + { + if (_TheSimpleFileFactory) { + _TheSimpleFileFactory->Set_Sub_Directory(_original.Peek_Buffer()); + } + } + + void append(const QString &path) + { + if (_TheSimpleFileFactory && !path.isEmpty()) { + const QByteArray native = QDir::toNativeSeparators(path).toLocal8Bit(); + _TheSimpleFileFactory->Append_Sub_Directory(native.constData()); + } + } + +private: + StringClass _original; +}; + +QString firstExisting(const QDir &root, std::initializer_list relativePaths) +{ + for (const char *relativePath : relativePaths) { + const QString path = root.filePath(QString::fromLatin1(relativePath)); + if (QFileInfo::exists(path)) { + return QFileInfo(path).absoluteFilePath(); + } + } + return {}; +} + +QString captureFrame(W3DViewport &viewport, const QString &outputDirectory, const QString &name) +{ + const QString base = QDir(outputDirectory).filePath(name); + const int number = viewport.captureScreenshot(base); + if (number <= 0) { + return {}; + } + return QStringLiteral("%1%2.tga").arg(base).arg(number, 2, 10, QLatin1Char('0')); +} + +QByteArray frameHash(const QString &path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return {}; + } + return QCryptographicHash::hash(file.readAll(), QCryptographicHash::Sha256); +} + +bool writeUncompressedTga(const QImage &source, const QString &path) +{ + const QImage image = source.convertToFormat(QImage::Format_ARGB32); + if (image.isNull() || image.width() > 65535 || image.height() > 65535) { + return false; + } + QByteArray header(18, '\0'); + header[2] = 2; + header[12] = static_cast(image.width() & 0xff); + header[13] = static_cast((image.width() >> 8) & 0xff); + header[14] = static_cast(image.height() & 0xff); + header[15] = static_cast((image.height() >> 8) & 0xff); + header[16] = 32; + header[17] = 0x28; + QFile output(path); + if (!output.open(QIODevice::WriteOnly | QIODevice::Truncate) || output.write(header) != 18) { + return false; + } + QByteArray row(image.width() * 4, '\0'); + for (int y = 0; y < image.height(); ++y) { + const QRgb *pixels = reinterpret_cast(image.constScanLine(y)); + for (int x = 0; x < image.width(); ++x) { + row[x * 4] = static_cast(qBlue(pixels[x])); + row[x * 4 + 1] = static_cast(qGreen(pixels[x])); + row[x * 4 + 2] = static_cast(qRed(pixels[x])); + row[x * 4 + 3] = static_cast(qAlpha(pixels[x])); + } + if (output.write(row) != row.size()) { + return false; + } + } + return true; +} + +QByteArray readTgaPixels(const QString &path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return {}; + } + const QByteArray bytes = file.readAll(); + if (bytes.size() < 18 || static_cast(bytes[2]) != 2) { + return {}; + } + const int width = static_cast(bytes[12]) + | (static_cast(bytes[13]) << 8); + const int height = static_cast(bytes[14]) + | (static_cast(bytes[15]) << 8); + const int bytesPerPixel = static_cast(bytes[16]) / 8; + const int offset = 18 + static_cast(bytes[0]); + const int pixelBytes = width * height * bytesPerPixel; + if (width <= 0 || height <= 0 || (bytesPerPixel != 3 && bytesPerPixel != 4) + || offset + pixelBytes > bytes.size()) { + return {}; + } + return bytes.mid(offset, pixelBytes); +} + +double meanPixelDifference(const QByteArray &first, const QByteArray &second) +{ + if (first.isEmpty() || first.size() != second.size()) { + return 0.0; + } + quint64 difference = 0; + for (qsizetype index = 0; index < first.size(); ++index) { + difference += std::abs(static_cast(static_cast(first[index])) + - static_cast(static_cast(second[index]))); + } + return static_cast(difference) / first.size(); +} +} // namespace + +class W3DViewportFogTests final : public QObject +{ + Q_OBJECT + +private slots: + void manualClipPlanesRecalculateFogRange(); + void appliedBackgroundsProduceDistinctFrames(); +}; + +void W3DViewportFogTests::manualClipPlanesRecalculateFogRange() +{ +#ifndef _WIN32 + QSKIP("The W3D viewport native regression requires Windows and Direct3D."); +#else + if (QGuiApplication::platformName().compare(QStringLiteral("windows"), + Qt::CaseInsensitive) != 0) { + QSKIP("The W3D viewport native regression requires the Qt Windows platform plugin."); + } + + W3DViewport viewport; + viewport.setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | + Qt::WindowDoesNotAcceptFocus | Qt::WindowStaysOnBottomHint); + viewport.setAttribute(Qt::WA_ShowWithoutActivating); + viewport.resize(320, 240); + viewport.move(-3200, -3200); + + viewport.setManualClipPlanesEnabled(true); + viewport.setCameraClipPlanes(10000.0f, 20000.0f); + + const HWND hwnd = reinterpret_cast(viewport.winId()); + QVERIFY(hwnd != nullptr); + QVERIFY(::SetWindowPos(hwnd, + HWND_BOTTOM, + -3200, + -3200, + 320, + 240, + SWP_NOACTIVATE | SWP_NOOWNERZORDER | SWP_NOSENDCHANGING)); + + viewport.show(); + QVERIFY(::SetWindowPos(hwnd, + HWND_BOTTOM, + -3200, + -3200, + 0, + 0, + SWP_NOSIZE | SWP_NOACTIVATE | SWP_NOOWNERZORDER | + SWP_NOSENDCHANGING)); + QCoreApplication::processEvents(QEventLoop::AllEvents, 100); + + float cameraNear = 0.0f; + float cameraFar = 0.0f; + viewport.cameraClipPlanes(cameraNear, cameraFar); + QCOMPARE(cameraNear, 10000.0f); + QCOMPARE(cameraFar, 20000.0f); + + float fogNear = 0.0f; + float fogFar = 0.0f; + QVERIFY2(viewport.sceneFogRange(fogNear, fogFar), + "The native viewport did not initialize its W3D scene."); + QCOMPARE(fogNear, 10000.0f); + QCOMPARE(fogFar, 10200.0f); + + viewport.setManualClipPlanesEnabled(false); + auto *sphere = new SphereRenderObjClass; + sphere->Set_Extent(Vector3(10.0f, 10.0f, 10.0f)); + viewport.setRenderObject(sphere); + sphere->Release_Ref(); + + viewport.cameraClipPlanes(cameraNear, cameraFar); + QVERIFY(cameraNear < 10000.0f); + QVERIFY(viewport.sceneFogRange(fogNear, fogFar)); + QCOMPARE(fogNear, cameraNear); + + viewport.setManualClipPlanesEnabled(true); + viewport.cameraClipPlanes(cameraNear, cameraFar); + QCOMPARE(cameraNear, 10000.0f); + QCOMPARE(cameraFar, 20000.0f); + QVERIFY(viewport.sceneFogRange(fogNear, fogFar)); + QCOMPARE(fogNear, 10000.0f); + QCOMPARE(fogFar, 10200.0f); + + viewport.setCameraClipPlanes(5000.0f, 6000.0f); + viewport.cameraClipPlanes(cameraNear, cameraFar); + QCOMPARE(cameraNear, 5000.0f); + QCOMPARE(cameraFar, 6000.0f); + QVERIFY(viewport.sceneFogRange(fogNear, fogFar)); + QCOMPARE(fogNear, 5000.0f); + QCOMPARE(fogFar, 5200.0f); + + viewport.setRenderObject(nullptr); + viewport.hide(); + QCoreApplication::processEvents(QEventLoop::AllEvents, 100); +#endif +} + +void W3DViewportFogTests::appliedBackgroundsProduceDistinctFrames() +{ +#ifndef _WIN32 + QSKIP("The applied-background regression requires Windows and Direct3D."); +#else + const QString externalRootPath = qEnvironmentVariable("W3DVIEW_EXTERNAL_ASSET_DIR"); + if (externalRootPath.isEmpty()) { + QSKIP("Set W3DVIEW_EXTERNAL_ASSET_DIR to run the external applied-background regression."); + } + if (QGuiApplication::platformName().compare(QStringLiteral("windows"), + Qt::CaseInsensitive) != 0) { + QSKIP("The applied-background regression requires the Qt Windows platform plugin."); + } + + const QDir externalRoot(externalRootPath); + const QString bitmapPath = firstExisting( + externalRoot, {"textures/mct_screen-fx.tga", "Always/mct_screen-fx.tga"}); + const QString modelPath = firstExisting( + externalRoot, {"w3d/c_chicken.w3d", "Always/c_chicken.w3d"}); + QVERIFY2(!bitmapPath.isEmpty(), "The supplied mct_screen-fx.tga was not found."); + QVERIFY2(!modelPath.isEmpty(), "The supplied c_chicken.w3d was not found."); + + // This dump's mct_screen-fx.tga contains BMP bytes. Preserve the source and + // stage a valid uncompressed TGA for the legacy extension-driven loader. + QTemporaryDir bitmapStaging; + QVERIFY(bitmapStaging.isValid()); + QString appliedBitmapPath = bitmapPath; + QFile bitmapSource(bitmapPath); + QVERIFY(bitmapSource.open(QIODevice::ReadOnly)); + if (bitmapSource.read(2) == QByteArrayLiteral("BM")) { + appliedBitmapPath = QDir(bitmapStaging.path()).filePath("mct_screen-fx.tga"); + bitmapSource.close(); + QVERIFY2(writeUncompressedTga(QImage(bitmapPath), appliedBitmapPath), + "Could not stage the mislabeled bitmap as TGA without modifying the source."); + } + + QTemporaryDir temporaryOutput; + QString outputDirectory = qEnvironmentVariable("W3DVIEW_VALIDATION_OUTPUT_DIR"); + if (outputDirectory.isEmpty()) { + QVERIFY(temporaryOutput.isValid()); + outputDirectory = temporaryOutput.path(); + } else { + QVERIFY2(QDir().mkpath(outputDirectory), "Could not create the validation output directory."); + } + + FileFactorySearchPathGuard searchPaths; + searchPaths.append(QFileInfo(bitmapPath).absolutePath()); + searchPaths.append(QFileInfo(modelPath).absolutePath()); + searchPaths.append(externalRoot.filePath("textures")); + searchPaths.append(externalRoot.filePath("Always")); + + auto *assetManager = WW3DAssetManager::Get_Instance(); + QVERIFY(assetManager); + const QByteArray modelNative = QDir::toNativeSeparators(modelPath).toLocal8Bit(); + QVERIFY2(assetManager->Load_3D_Assets(modelNative.constData()), "c_chicken.w3d failed to load."); + QVERIFY2(assetManager->Render_Obj_Exists("C_CHICKEN"), "C_CHICKEN was not registered."); + + W3DViewport viewport; + viewport.setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | + Qt::WindowDoesNotAcceptFocus | Qt::WindowStaysOnBottomHint); + viewport.setAttribute(Qt::WA_ShowWithoutActivating); + viewport.resize(320, 240); + viewport.move(-3200, -3200); + const HWND hwnd = reinterpret_cast(viewport.winId()); + QVERIFY(hwnd); + QVERIFY(::SetWindowPos(hwnd, HWND_BOTTOM, -3200, -3200, 320, 240, + SWP_NOACTIVATE | SWP_NOOWNERZORDER | SWP_NOSENDCHANGING)); + viewport.show(); + QVERIFY(::SetWindowPos(hwnd, HWND_BOTTOM, -3200, -3200, 0, 0, + SWP_NOSIZE | SWP_NOACTIVATE | SWP_NOOWNERZORDER | + SWP_NOSENDCHANGING)); + QCoreApplication::processEvents(QEventLoop::AllEvents, 100); + + viewport.setBackgroundBitmap({}); + viewport.setBackgroundObjectName({}); + viewport.setBackgroundColor(Vector3(0.05f, 0.15f, 0.35f)); + const QString solidPath = captureFrame(viewport, outputDirectory, "background-solid-"); + + viewport.setBackgroundBitmap(appliedBitmapPath); + const QString bitmapOutputPath = captureFrame(viewport, outputDirectory, "background-bitmap-"); + + viewport.setBackgroundBitmap({}); + viewport.setBackgroundObjectName(QStringLiteral("C_CHICKEN")); + const QString objectPath = captureFrame(viewport, outputDirectory, "background-object-"); + + const QStringList paths{solidPath, bitmapOutputPath, objectPath}; + QList pixels; + QList hashes; + for (const QString &path : paths) { + QVERIFY2(!path.isEmpty(), "Screenshot capture failed."); + QVERIFY2(QFileInfo(path).size() > 18, qPrintable(QString("Empty screenshot: %1").arg(path))); + const QByteArray framePixels = readTgaPixels(path); + QVERIFY2(!framePixels.isEmpty(), qPrintable(QString("Unreadable screenshot: %1").arg(path))); + pixels.append(framePixels); + hashes.append(frameHash(path)); + qInfo().noquote() << "W3DVIEW_BACKGROUND_EVIDENCE" << path + << hashes.last().toHex(); + } + + QVERIFY(hashes[0] != hashes[1]); + QVERIFY(hashes[0] != hashes[2]); + QVERIFY(hashes[1] != hashes[2]); + QVERIFY2(meanPixelDifference(pixels[0], pixels[1]) >= 0.05, + "Solid-color and bitmap frames were not materially distinct."); + QVERIFY2(meanPixelDifference(pixels[0], pixels[2]) >= 0.05, + "Solid-color and background-object frames were not materially distinct."); + QVERIFY2(meanPixelDifference(pixels[1], pixels[2]) >= 0.05, + "Bitmap and background-object frames were not materially distinct."); + + viewport.setBackgroundObjectName({}); + viewport.hide(); + QCoreApplication::processEvents(QEventLoop::AllEvents, 100); +#endif +} + +int main(int argc, char **argv) +{ + QApplication application(argc, argv); + WWMath::Init(); + + int result = 0; + { + WW3DAssetManager assetManager; + assetManager.Set_WW3D_Load_On_Demand(true); + assetManager.Set_Activate_Fog_On_Load(true); + + W3DViewportFogTests tests; + result = QTest::qExec(&tests, argc, argv); + } + + WWMath::Shutdown(); + return result; +} + +#include "W3DViewportFogTests.moc" diff --git a/Code/WWAudio/AudibleSound.cpp b/Code/WWAudio/AudibleSound.cpp index c196b6a50..c6341db5d 100644 --- a/Code/WWAudio/AudibleSound.cpp +++ b/Code/WWAudio/AudibleSound.cpp @@ -1600,15 +1600,21 @@ AudibleSoundDefinitionClass::Save (ChunkSaveClass &csave) using namespace AUDIBLE_SOUND_DEF_SAVELOAD; bool retval = true; - csave.Begin_Chunk (CHUNKID_VARIABLES); - retval &= Save_Variables (csave); - csave.End_Chunk (); + bool saved_variables = false; + if (csave.Begin_Chunk (CHUNKID_VARIABLES)) { + saved_variables = Save_Variables (csave); + saved_variables = csave.End_Chunk () && saved_variables; + } + retval &= saved_variables; - csave.Begin_Chunk (CHUNKID_BASE_CLASS); - retval &= DefinitionClass::Save (csave); - csave.End_Chunk (); + bool saved_base_class = false; + if (csave.Begin_Chunk (CHUNKID_BASE_CLASS)) { + saved_base_class = DefinitionClass::Save (csave); + saved_base_class = csave.End_Chunk () && saved_base_class; + } + retval &= saved_base_class; - return retval; + return retval && !csave.Has_Write_Error (); } @@ -1651,35 +1657,46 @@ bool AudibleSoundDefinitionClass::Save_Variables (ChunkSaveClass &csave) { using namespace AUDIBLE_SOUND_DEF_SAVELOAD; + bool retval = true; // // Save the audible variables // - WRITE_MICRO_CHUNK (csave, VARID_PRIORITY, m_Priority) - WRITE_MICRO_CHUNK (csave, VARID_VOLUME, m_Volume) - WRITE_MICRO_CHUNK (csave, VARID_PAN, m_Pan) - WRITE_MICRO_CHUNK (csave, VARID_LOOP_COUNT, m_LoopCount) - WRITE_MICRO_CHUNK (csave, VARID_DROP_OFF, m_DropOffRadius) - WRITE_MICRO_CHUNK (csave, VARID_MAX_VOL, m_MaxVolRadius) - WRITE_MICRO_CHUNK (csave, VARID_TYPE, m_Type) - WRITE_MICRO_CHUNK (csave, VARID_IS3D, m_Is3D) - WRITE_MICRO_CHUNK_WWSTRING (csave, VARID_FILENAME, m_Filename) - WRITE_MICRO_CHUNK_WWSTRING (csave, VARID_DISPLAY_TEXT, m_DisplayText) - WRITE_MICRO_CHUNK (csave, VARID_START_OFFSET, m_StartOffset); - WRITE_MICRO_CHUNK (csave, VARID_PITCH_FACTOR, m_PitchFactor); - WRITE_MICRO_CHUNK (csave, VARID_PITCH_FACTOR_RND, m_PitchFactorRandomizer); - WRITE_MICRO_CHUNK (csave, VARID_VOLUME_RND, m_VolumeRandomizer); - WRITE_MICRO_CHUNK (csave, VARID_VIRTUAL_CHANNEL, m_VirtualChannel); + retval &= csave.Write_Micro_Chunk (VARID_PRIORITY, &m_Priority, sizeof (m_Priority)); + retval &= csave.Write_Micro_Chunk (VARID_VOLUME, &m_Volume, sizeof (m_Volume)); + retval &= csave.Write_Micro_Chunk (VARID_PAN, &m_Pan, sizeof (m_Pan)); + retval &= csave.Write_Micro_Chunk (VARID_LOOP_COUNT, &m_LoopCount, sizeof (m_LoopCount)); + retval &= csave.Write_Micro_Chunk (VARID_DROP_OFF, &m_DropOffRadius, sizeof (m_DropOffRadius)); + retval &= csave.Write_Micro_Chunk (VARID_MAX_VOL, &m_MaxVolRadius, sizeof (m_MaxVolRadius)); + retval &= csave.Write_Micro_Chunk (VARID_TYPE, &m_Type, sizeof (m_Type)); + retval &= csave.Write_Micro_Chunk (VARID_IS3D, &m_Is3D, sizeof (m_Is3D)); + retval &= csave.Write_Micro_Chunk (VARID_FILENAME, (const char *)m_Filename, + static_cast(m_Filename.Get_Length ()) + 1); + retval &= csave.Write_Micro_Chunk (VARID_DISPLAY_TEXT, (const char *)m_DisplayText, + static_cast(m_DisplayText.Get_Length ()) + 1); + retval &= csave.Write_Micro_Chunk (VARID_START_OFFSET, &m_StartOffset, sizeof (m_StartOffset)); + retval &= csave.Write_Micro_Chunk (VARID_PITCH_FACTOR, &m_PitchFactor, sizeof (m_PitchFactor)); + retval &= csave.Write_Micro_Chunk (VARID_PITCH_FACTOR_RND, &m_PitchFactorRandomizer, + sizeof (m_PitchFactorRandomizer)); + retval &= csave.Write_Micro_Chunk (VARID_VOLUME_RND, &m_VolumeRandomizer, + sizeof (m_VolumeRandomizer)); + retval &= csave.Write_Micro_Chunk (VARID_VIRTUAL_CHANNEL, &m_VirtualChannel, + sizeof (m_VirtualChannel)); // // Save the logical variables // - WRITE_MICRO_CHUNK (csave, VARID_LOGICAL_MASK, m_LogicalTypeMask) - WRITE_MICRO_CHUNK (csave, VARID_LOGICAL_DELAY, m_LogicalNotifyDelay) - WRITE_MICRO_CHUNK (csave, VARID_CREATE_LOGICAL, m_CreateLogical) - WRITE_MICRO_CHUNK (csave, VARID_LOGICAL_DROP_OFF, m_LogicalDropOffRadius) - WRITE_MICRO_CHUNK (csave, VARID_SPHERE_COLOR, m_AttenuationSphereColor) - return true; + retval &= csave.Write_Micro_Chunk (VARID_LOGICAL_MASK, &m_LogicalTypeMask, + sizeof (m_LogicalTypeMask)); + retval &= csave.Write_Micro_Chunk (VARID_LOGICAL_DELAY, &m_LogicalNotifyDelay, + sizeof (m_LogicalNotifyDelay)); + retval &= csave.Write_Micro_Chunk (VARID_CREATE_LOGICAL, &m_CreateLogical, + sizeof (m_CreateLogical)); + retval &= csave.Write_Micro_Chunk (VARID_LOGICAL_DROP_OFF, &m_LogicalDropOffRadius, + sizeof (m_LogicalDropOffRadius)); + retval &= csave.Write_Micro_Chunk (VARID_SPHERE_COLOR, &m_AttenuationSphereColor, + sizeof (m_AttenuationSphereColor)); + return retval && !csave.Has_Write_Error (); } diff --git a/Code/WWAudio/AudibleSound.h b/Code/WWAudio/AudibleSound.h index aa832bf0e..ad5656bc2 100644 --- a/Code/WWAudio/AudibleSound.h +++ b/Code/WWAudio/AudibleSound.h @@ -443,6 +443,10 @@ class AudibleSoundDefinitionClass : public DefinitionClass virtual float Get_Pitch_Factor (void) const { return m_PitchFactor; } virtual float Get_Pitch_Factor_Randomizer (void) const { return m_PitchFactorRandomizer; } virtual int Get_Virtual_Channel (void) const { return m_VirtualChannel; } + virtual float Get_Priority (void) const { return m_Priority; } + virtual int Get_Loop_Count (void) const { return m_LoopCount; } + virtual bool Is_3D (void) const { return m_Is3D; } + virtual int Get_Type (void) const { return m_Type; } virtual void Set_Volume (float volume) { m_Volume = volume; } virtual void Set_Volume_Randomizer (float value) { m_VolumeRandomizer = value; } diff --git a/Code/WWAudio/CMakeLists.txt b/Code/WWAudio/CMakeLists.txt index f48e27374..3b6506cc8 100644 --- a/Code/WWAudio/CMakeLists.txt +++ b/Code/WWAudio/CMakeLists.txt @@ -109,3 +109,27 @@ if (W3D_TOOLS) target_link_libraries(wwaudioe PRIVATE milesstub) endif() endif() + +if(BUILD_TESTING AND W3D_BUILD_OPTION_OPENAL) + add_executable(wwaudio_openal_tests + tests/OpenALAudioTests.cpp + ) + + target_link_libraries(wwaudio_openal_tests PRIVATE + wwaudio + ww3d2 + wwdebug + wwlib + wwmath + wwphys + wwsaveload + wwcommon + version + $<$:winmm> + ) + + add_test(NAME wwaudio_openal_tests COMMAND wwaudio_openal_tests) + set_tests_properties(wwaudio_openal_tests PROPERTIES + ENVIRONMENT "ALSOFT_DRIVERS=null" + ) +endif() diff --git a/Code/WWAudio/WWAudio.cpp b/Code/WWAudio/WWAudio.cpp index fc661d296..beaf773fe 100644 --- a/Code/WWAudio/WWAudio.cpp +++ b/Code/WWAudio/WWAudio.cpp @@ -1261,7 +1261,12 @@ WWAudioClass::Remove_From_Playlist (AudibleSoundClass *sound_obj) // // Add this sound to the 'completed' list // - m_CompletedSounds.Add (sound_obj); + // A sound can be stopped, replayed, and stopped again before + // the next frame drains this deferred-release queue. Keep only + // one entry so the playlist's single reference is released once. + if (m_CompletedSounds.ID (sound_obj) == -1) { + m_CompletedSounds.Add (sound_obj); + } retval = true; } } diff --git a/Code/WWAudio/tests/OpenALAudioTests.cpp b/Code/WWAudio/tests/OpenALAudioTests.cpp new file mode 100644 index 000000000..f3dbfd4ed --- /dev/null +++ b/Code/WWAudio/tests/OpenALAudioTests.cpp @@ -0,0 +1,238 @@ +#include "AudibleSound.h" +#include "Sound3D.h" +#include "WWAudio.h" +#include "openal/FFMpegBuffer.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +template +struct ReleaseRef +{ + void operator()(T *value) const + { + if (value) { + value->Release_Ref(); + } + } +}; + +class TemporaryWav +{ +public: + TemporaryWav() + { + const auto suffix = std::chrono::steady_clock::now().time_since_epoch().count(); + _path = std::filesystem::temp_directory_path() / + ("openw3d-openal-" + std::to_string(suffix) + ".wav"); + } + + ~TemporaryWav() + { + std::error_code error; + std::filesystem::remove(_path, error); + } + + bool write() + { + constexpr std::uint32_t sampleRate = 22050; + constexpr std::uint16_t channels = 1; + constexpr std::uint16_t bitsPerSample = 16; + constexpr std::uint32_t sampleCount = sampleRate; + constexpr std::uint32_t bytesPerSample = bitsPerSample / 8; + constexpr std::uint32_t dataSize = sampleCount * bytesPerSample; + + std::ofstream stream(_path, std::ios::binary | std::ios::trunc); + if (!stream) { + return false; + } + + const auto write16 = [&stream](std::uint16_t value) { + const char bytes[] = { + static_cast(value & 0xff), + static_cast((value >> 8) & 0xff), + }; + stream.write(bytes, sizeof(bytes)); + }; + const auto write32 = [&stream](std::uint32_t value) { + const char bytes[] = { + static_cast(value & 0xff), + static_cast((value >> 8) & 0xff), + static_cast((value >> 16) & 0xff), + static_cast((value >> 24) & 0xff), + }; + stream.write(bytes, sizeof(bytes)); + }; + + stream.write("RIFF", 4); + write32(36 + dataSize); + stream.write("WAVE", 4); + stream.write("fmt ", 4); + write32(16); + write16(1); + write16(channels); + write32(sampleRate); + write32(sampleRate * channels * bytesPerSample); + write16(channels * bytesPerSample); + write16(bitsPerSample); + stream.write("data", 4); + write32(dataSize); + + for (std::uint32_t index = 0; index < sampleCount; ++index) { + const std::int16_t sample = ((index / 55) % 2) ? 4096 : -4096; + write16(static_cast(sample)); + } + + return stream.good(); + } + + const std::filesystem::path &path() const + { + return _path; + } + +private: + std::filesystem::path _path; +}; + +bool expect(bool condition, const char *message, int &failures) +{ + if (!condition) { + std::cerr << "FAIL: " << message << '\n'; + ++failures; + } + return condition; +} +} // namespace + +int main() +{ + int failures = 0; + TemporaryWav fixture; + if (!expect(fixture.write(), "could not create the generated PCM WAV fixture", failures)) { + return 1; + } + + const std::string filename = fixture.path().string(); + std::error_code sizeError; + const std::uintmax_t fixtureSize = std::filesystem::file_size(fixture.path(), sizeError); + expect(!sizeError && fixtureSize > DEF_MAX_2D_BUFFER_SIZE && + fixtureSize <= DEF_MAX_3D_BUFFER_SIZE * 2, + "generated WAV did not straddle the 2D-streaming and 3D-static thresholds", + failures); + + { + std::unique_ptr> decoded( + new FFMpegBufferClass); + const bool loaded = decoded->Load_From_File(filename.c_str(), false); + expect(loaded, "FFmpeg could not decode the generated PCM WAV", failures); + if (loaded) { + expect(decoded->Get_Channels() == 1, "decoded WAV was not mono", failures); + expect(decoded->Get_Rate() == 22050, "decoded WAV sample rate changed", failures); + expect(decoded->Get_Bits() == 16, "decoded WAV was not converted to PCM16", failures); + expect(decoded->Get_Duration() >= 990 && decoded->Get_Duration() <= 1010, + "decoded WAV duration was not approximately one second", + failures); + expect(decoded->Get_Raw_Length() > 0, "decoded WAV had no PCM payload", failures); + } + } + + { + std::unique_ptr> streaming( + new FFMpegBufferClass); + const bool loaded = streaming->Load_From_File(filename.c_str(), true); + expect(loaded, "FFmpeg could not open the generated WAV for streaming", failures); + if (loaded) { + expect(streaming->Is_Streaming(), + "FFmpeg did not mark the generated WAV as streaming", + failures); + expect(streaming->Get_Raw_Length() == 0, + "streaming WAV unexpectedly predecoded a static PCM payload", + failures); + expect(streaming->Get_Channels() == 1, + "streaming WAV metadata was not mono", + failures); + } + } + + std::unique_ptr audio(WWAudioClass::Create_Instance()); + if (!expect(audio != nullptr, "WWAudioClass::Create_Instance returned null", failures)) { + return 1; + } + audio->Initialize(); + + expect(WWAudioClass::Get_Instance() == audio.get(), + "WWAudio singleton did not reference the OpenAL instance", + failures); + expect(std::strcmp(audio->Get_3D_Driver_Name().Peek_Buffer(), "OpenAL 3D Audio") == 0, + "configured WWAudio backend was not OpenAL", + failures); + expect(audio->Get_2D_Sample_Count() == DEF_2D_SAMPLE_COUNT, + "OpenAL did not allocate the expected 2D source pool", + failures); + expect(audio->Get_3D_Sample_Count() == DEF_3D_SAMPLE_COUNT, + "OpenAL did not allocate the expected 3D source pool", + failures); + + { + std::unique_ptr> sound( + audio->Create_Sound_Effect(filename.c_str())); + if (expect(sound != nullptr, "OpenAL could not create a 2D sound", failures)) { + expect(sound->Play(), "OpenAL 2D Play returned false", failures); + expect(sound->Is_Playing(), "OpenAL 2D sound did not enter playing state", failures); + + bool ownsSource = false; + for (int index = 0; index < audio->Get_2D_Sample_Count(); ++index) { + ownsSource |= audio->Peek_2D_Sample(index) == sound.get(); + } + expect(ownsSource, "OpenAL 2D sound did not acquire a real source", failures); + expect(sound->Stop(), "OpenAL 2D Stop returned false", failures); + expect(!sound->Is_Playing(), "OpenAL 2D sound did not stop", failures); + expect(sound->Play(), "OpenAL 2D replay returned false", failures); + expect(sound->Is_Playing(), "OpenAL 2D sound did not resume on replay", failures); + expect(sound->Stop(), "OpenAL 2D second Stop returned false", failures); + expect(!sound->Is_Playing(), "OpenAL 2D sound did not stop after replay", failures); + } + } + + { + std::unique_ptr> sound( + audio->Create_3D_Sound(filename.c_str(), CLASSID_3D)); + if (expect(sound != nullptr, "OpenAL could not create a 3D sound", failures)) { + expect(sound->Get_Class_ID() == CLASSID_3D, + "mono PCM WAV fell back to pseudo-3D", + failures); + sound->Cull_Sound(false); + expect(sound->Play(), "OpenAL 3D Play returned false", failures); + expect(sound->Is_Playing(), "OpenAL 3D sound did not enter playing state", failures); + + bool ownsSource = false; + for (int index = 0; index < audio->Get_3D_Sample_Count(); ++index) { + ownsSource |= audio->Peek_3D_Sample(index) == sound.get(); + } + expect(ownsSource, "OpenAL 3D sound did not acquire a real source", failures); + expect(sound->Stop(), "OpenAL 3D Stop returned false", failures); + expect(!sound->Is_Playing(), "OpenAL 3D sound did not stop", failures); + } + } + + audio.reset(); + expect(WWAudioClass::Get_Instance() == nullptr, + "OpenAL destruction did not clear the WWAudio singleton", + failures); + + if (failures != 0) { + std::cerr << failures << " OpenAL regression assertion(s) failed.\n"; + return 1; + } + + std::cout << "OpenAL backend initialization, decode, 2D, 3D, and teardown passed.\n"; + return 0; +} diff --git a/Code/ww3d2/CMakeLists.txt b/Code/ww3d2/CMakeLists.txt index 8f4b473de..b68ea70b6 100644 --- a/Code/ww3d2/CMakeLists.txt +++ b/Code/ww3d2/CMakeLists.txt @@ -255,3 +255,28 @@ if (W3D_TOOLS) target_sources(ww3d2e PRIVATE ${WW3D2_SRC}) endif() + +if(BUILD_TESTING AND WIN32) + add_executable(ww3d2_screenshot_api_tests + tests/ScreenshotApiTests.cpp + ) + + target_link_libraries(ww3d2_screenshot_api_tests PRIVATE + ww3d2 + wwcommon + ) + + add_test(NAME ww3d2_screenshot_api_tests COMMAND ww3d2_screenshot_api_tests) + + add_executable(ww3d2_framegrab_tests + tests/FrameGrabTests.cpp + ) + + target_link_libraries(ww3d2_framegrab_tests PRIVATE + ww3d2 + wwcommon + vfw32 + ) + + add_test(NAME ww3d2_framegrab_tests COMMAND ww3d2_framegrab_tests) +endif() diff --git a/Code/ww3d2/agg_def.cpp b/Code/ww3d2/agg_def.cpp index 6ae4d844c..9ec7bf541 100644 --- a/Code/ww3d2/agg_def.cpp +++ b/Code/ww3d2/agg_def.cpp @@ -725,7 +725,9 @@ AggregateDefClass::Save_W3D (ChunkSaveClass &chunk_save) } // Close the aggregate chunk - chunk_save.End_Chunk (); + if (!chunk_save.End_Chunk ()) { + ret_val = WW3D_ERROR_SAVE_FAILED; + } } // Return the WW3D_ERROR_TYPE return code @@ -759,7 +761,9 @@ AggregateDefClass::Save_Header (ChunkSaveClass &chunk_save) } // End the header chunk - chunk_save.End_Chunk (); + if (!chunk_save.End_Chunk ()) { + ret_val = WW3D_ERROR_SAVE_FAILED; + } } // Return the WW3D_ERROR_TYPE return code @@ -796,7 +800,9 @@ AggregateDefClass::Save_Info (ChunkSaveClass &chunk_save) } // End the settings chunk - chunk_save.End_Chunk (); + if (!chunk_save.End_Chunk ()) { + ret_val = WW3D_ERROR_SAVE_FAILED; + } } // Return the WW3D_ERROR_TYPE return code @@ -851,7 +857,9 @@ AggregateDefClass::Save_Class_Info (ChunkSaveClass &chunk_save) } // End the class info chunk - chunk_save.End_Chunk (); + if (!chunk_save.End_Chunk ()) { + ret_val = WW3D_ERROR_SAVE_FAILED; + } } // Return the WW3D_ERROR_TYPE return code @@ -889,4 +897,3 @@ AggregateLoaderClass::Load_W3D (ChunkLoadClass &chunk_load) // Return a pointer to the prototype return pprototype; } - diff --git a/Code/ww3d2/agg_def.h b/Code/ww3d2/agg_def.h index 191c68277..5b5065b7a 100644 --- a/Code/ww3d2/agg_def.h +++ b/Code/ww3d2/agg_def.h @@ -110,6 +110,7 @@ class AggregateDefClass virtual WW3DErrorType Load_W3D (ChunkLoadClass &chunk_load); virtual WW3DErrorType Save_W3D (ChunkSaveClass &chunk_save); const char * Get_Name (void) const { return m_pName; } + const char * Get_Base_Model_Name (void) const { return m_Info.BaseModelName; } void Set_Name (const char *pname) { SAFE_FREE (m_pName); m_pName = ::strdup (pname); } RenderObjClass * Create (void); AggregateDefClass * Clone (void) const { return new AggregateDefClass (*this); } diff --git a/Code/ww3d2/dazzle.cpp b/Code/ww3d2/dazzle.cpp index caf0a0e0d..743b06bc0 100644 --- a/Code/ww3d2/dazzle.cpp +++ b/Code/ww3d2/dazzle.cpp @@ -901,6 +901,11 @@ void DazzleRenderObjClass::Render(RenderInfoClass & rinfo) _dazzle_rendering_enabled && !DX8Wrapper::Is_Render_To_Texture() ) { + if (!types || type >= type_count || !types[type]) { + visibility = 0.0f; + return; + } + // First check if the dazzle is blinking and is "off" bool is_on = true; DazzleInitClass & ic = types[type]->ic; diff --git a/Code/ww3d2/dx8wrapper.cpp b/Code/ww3d2/dx8wrapper.cpp index adf1e1acd..3a38ca916 100644 --- a/Code/ww3d2/dx8wrapper.cpp +++ b/Code/ww3d2/dx8wrapper.cpp @@ -531,7 +531,17 @@ bool DX8Wrapper::Reset_Device(void) // Reset frame count to reflect the flipping chain being reset by Reset() FrameCount = 0; - DX8CALL(Reset(&_PresentParameters)); + const HRESULT reset_result = D3DDevice->Reset(&_PresentParameters); + number_of_DX8_calls++; + if (FAILED(reset_result)) { + IsDeviceLost = true; + Non_Fatal_Log_DX8_ErrorCode(reset_result, __FILE__, __LINE__); + WWDEBUG_SAY(("Device reset failed (HRESULT 0x%08x)\n", + static_cast(reset_result))); + return false; + } + + IsDeviceLost = false; DX8TextureManagerClass::Recreate_Textures(); Invalidate_Cached_Render_States(); Set_Default_Global_Render_States(); @@ -1025,6 +1035,9 @@ const char * DX8Wrapper::Get_Render_Device_Name(int device_index) bool DX8Wrapper::Set_Device_Resolution(int width,int height,int /*bits*/,int /*windowed*/, bool /*resize_window*/) { if (D3DDevice != nullptr) { + const D3DPRESENT_PARAMETERS previous_parameters = _PresentParameters; + const int previous_width = ResolutionWidth; + const int previous_height = ResolutionHeight; if (width != -1) { _PresentParameters.BackBufferWidth = ResolutionWidth = width; @@ -1033,7 +1046,20 @@ bool DX8Wrapper::Set_Device_Resolution(int width,int height,int /*bits*/,int /*w _PresentParameters.BackBufferHeight = ResolutionHeight = height; } // FIXME TODO: support changing windowed status and changing the bit depth - return Reset_Device(); + if (Reset_Device()) { + return true; + } + + // A failed Reset leaves the device unusable until another Reset succeeds. + // Restore the last working presentation parameters and attempt to recover, + // while still reporting the requested mode as a failure to the caller. + _PresentParameters = previous_parameters; + ResolutionWidth = previous_width; + ResolutionHeight = previous_height; + if (!Reset_Device()) { + WWDEBUG_SAY(("Failed to restore the previous device resolution.\n")); + } + return false; } else { return false; } @@ -2733,6 +2759,12 @@ unsigned int DX8Wrapper::Get_Free_Texture_RAM() // Contrast - controls the difference between the maximum and the minimum of the curve void DX8Wrapper::Set_Gamma(float gamma,float bright,float contrast,bool calibrate,bool uselimit) { + // Device/caps can be unavailable during early startup (e.g., Qt viewer settings load). + // In that case, ignore gamma updates until initialization completes. + if (CurrentCaps == nullptr || _Get_D3D_Device8() == nullptr) { + return; + } + gamma=Bound(gamma,0.6f,6.0f); bright=Bound(bright,-0.5f,0.5f); contrast=Bound(contrast,0.5f,2.0f); diff --git a/Code/ww3d2/framgrab.cpp b/Code/ww3d2/framgrab.cpp index d457e246a..f2db5669e 100644 --- a/Code/ww3d2/framgrab.cpp +++ b/Code/ww3d2/framgrab.cpp @@ -21,167 +21,224 @@ ////////////////////////////////////////////////////////////////////// #include "framgrab.h" -#include +#include +#include #include -//#include +#include +#include ////////////////////////////////////////////////////////////////////// // Construction/Destruction ////////////////////////////////////////////////////////////////////// -FrameGrabClass::FrameGrabClass(const char *filename, MODE mode, int width, int height, int bitcount, float framerate) +FrameGrabClass::FrameGrabClass(const char *filename, MODE mode, int width, int height, int bitcount, float framerate) : + FrameRate(framerate), + Mode(mode), + Counter(0), + Width(width), + Height(height), + BufferStride(Calculate_Row_Stride(width, bitcount)), + AVIInitialized(false), + Ready(false), + LastError(S_OK), + AVIFile(nullptr), + Bitmap(nullptr), + Stream(nullptr) { - HRESULT hr; + std::memset(&AVIStreamInfo, 0, sizeof(AVIStreamInfo)); + std::memset(&BitmapInfoHeader, 0, sizeof(BitmapInfoHeader)); - Mode = mode; - Filename = filename; - FrameRate = framerate; - Counter = 0; - - Stream = 0; - AVIFile = 0; + if (Mode != AVI) { + return; + } - if(Mode != AVI) return; + const unsigned long long image_size = + static_cast(BufferStride) * static_cast(Height); + if (filename == nullptr || filename[0] == '\0' || Width <= 0 || Height <= 0 || + bitcount <= 0 || FrameRate <= 0.0f || BufferStride == 0 || + image_size > std::numeric_limits::max()) { + LastError = E_INVALIDARG; + Mode = RAW; + return; + } - AVIFileInit(); // opens AVIFile library + AVIFileInit(); + AVIInitialized = true; - // find the first free file with this prefix + // Find the first free file with this prefix. int counter = 0; int result; - char file[256]; + std::string file; do { - sprintf(file, "%s%d.AVI", filename, counter++); - result = _access(file, 0); - } while(result != -1); - - // Create new AVI file using AVIFileOpenA. - hr = AVIFileOpenA(&AVIFile, file, OF_WRITE | OF_CREATE, nullptr); - if (hr != 0) { - char buf[256]; - sprintf(buf, "Unable to open %s\n", Filename); - OutputDebugStringA(buf); + file = std::string(filename) + std::to_string(counter++) + ".AVI"; + result = _access(file.c_str(), 0); + } while (result != -1); + + HRESULT hr = AVIFileOpenA(&AVIFile, file.c_str(), OF_WRITE | OF_CREATE, nullptr); + if (FAILED(hr)) { + LastError = hr; + OutputDebugStringA("Unable to open AVI movie capture file.\n"); CleanupAVI(); return; } + // Set the format of the new stream. + BitmapInfoHeader.biWidth = Width; + BitmapInfoHeader.biHeight = Height; + BitmapInfoHeader.biBitCount = static_cast(bitcount); + BitmapInfoHeader.biSizeImage = static_cast(image_size); + BitmapInfoHeader.biSize = sizeof(BITMAPINFOHEADER); + BitmapInfoHeader.biPlanes = 1; + BitmapInfoHeader.biCompression = BI_RGB; + BitmapInfoHeader.biXPelsPerMeter = 1; + BitmapInfoHeader.biYPelsPerMeter = 1; + BitmapInfoHeader.biClrUsed = 0; + BitmapInfoHeader.biClrImportant = 0; - // Create a stream using AVIFileCreateStreamA. AVIStreamInfo.fccType = streamtypeVIDEO; AVIStreamInfo.fccHandler = mmioFOURCC('M','S','V','C'); - AVIStreamInfo.dwFlags = 0; - AVIStreamInfo.dwCaps = 0; - AVIStreamInfo.wPriority = 0; - AVIStreamInfo.wLanguage = 0; AVIStreamInfo.dwScale = 1; - AVIStreamInfo.dwRate = (int)FrameRate; - AVIStreamInfo.dwStart = 0; - AVIStreamInfo.dwLength = 0; - AVIStreamInfo.dwInitialFrames = 0; - AVIStreamInfo.dwSuggestedBufferSize = 0; - AVIStreamInfo.dwQuality = 0; - AVIStreamInfo.dwSampleSize = 0; - SetRect(&AVIStreamInfo.rcFrame, 0, 0, width, height); - AVIStreamInfo.dwEditCount = 0; - AVIStreamInfo.dwFormatChangeCount = 0; - sprintf(AVIStreamInfo.szName,"G"); - - hr = AVIFileCreateStreamA(AVIFile, &Stream, &AVIStreamInfo); - if (hr != 0) { + AVIStreamInfo.dwRate = static_cast(FrameRate); + if (AVIStreamInfo.dwRate == 0) { + AVIStreamInfo.dwRate = 1; + } + AVIStreamInfo.dwSuggestedBufferSize = BitmapInfoHeader.biSizeImage; + SetRect(&AVIStreamInfo.rcFrame, 0, 0, Width, Height); + AVIStreamInfo.szName[0] = 'G'; + + hr = AVIFileCreateStreamA(AVIFile, &Stream, &AVIStreamInfo); + if (FAILED(hr)) { + LastError = hr; CleanupAVI(); return; } - // Set format of new stream - BitmapInfoHeader.biWidth = width; - BitmapInfoHeader.biHeight = height; - BitmapInfoHeader.biBitCount = (unsigned short)bitcount; - BitmapInfoHeader.biSizeImage = ((((UINT)BitmapInfoHeader.biBitCount * BitmapInfoHeader.biWidth + 31) & ~31) / 8) * BitmapInfoHeader.biHeight; - BitmapInfoHeader.biSize = sizeof(BITMAPINFOHEADER); // size of structure - BitmapInfoHeader.biPlanes = 1; // must be set to 1 - BitmapInfoHeader.biCompression = BI_RGB; // uncompressed - BitmapInfoHeader.biXPelsPerMeter = 1; // not used - BitmapInfoHeader.biYPelsPerMeter = 1; // not used - BitmapInfoHeader.biClrUsed = 0; // all colors are used - BitmapInfoHeader.biClrImportant = 0; // all colors are important - - hr = AVIStreamSetFormat(Stream, 0, &BitmapInfoHeader, sizeof(BitmapInfoHeader)); - if (hr != 0) { + hr = AVIStreamSetFormat(Stream, 0, &BitmapInfoHeader, sizeof(BitmapInfoHeader)); + if (FAILED(hr)) { + LastError = hr; CleanupAVI(); return; } - Bitmap = (int *) GlobalAllocPtr(GMEM_MOVEABLE, BitmapInfoHeader.biSizeImage); + Bitmap = static_cast(GlobalAllocPtr(GMEM_MOVEABLE, BitmapInfoHeader.biSizeImage)); + if (Bitmap == nullptr) { + LastError = E_OUTOFMEMORY; + CleanupAVI(); + return; + } + + Ready = true; } FrameGrabClass::~FrameGrabClass() { - if(Mode == AVI) { - CleanupAVI(); - } + CleanupAVI(); } -void FrameGrabClass::CleanupAVI() { - if(Bitmap != 0) { GlobalFreePtr(Bitmap); Bitmap = 0; } - if(Stream != 0) { AVIStreamRelease(Stream); Stream = 0; } - if(AVIFile != 0) { AVIFileRelease(AVIFile); AVIFile = 0; } +void FrameGrabClass::CleanupAVI() +{ + Ready = false; + if (Bitmap != nullptr) { + GlobalFreePtr(Bitmap); + Bitmap = nullptr; + } + if (Stream != nullptr) { + AVIStreamRelease(Stream); + Stream = nullptr; + } + if (AVIFile != nullptr) { + AVIFileRelease(AVIFile); + AVIFile = nullptr; + } - AVIFileExit(); + if (AVIInitialized) { + AVIFileExit(); + AVIInitialized = false; + } Mode = RAW; } -void FrameGrabClass::GrabAVI(void *BitmapPointer) +bool FrameGrabClass::GrabAVI(void *BitmapPointer) { - // CompressDIB(&bi, lpOld, &biNew, lpNew); + if (!Ready || Stream == nullptr || BitmapPointer == nullptr) { + LastError = E_POINTER; + Ready = false; + return false; + } - // Save the compressed data using AVIStreamWrite. - HRESULT hr = AVIStreamWrite(Stream, Counter++, 1, BitmapPointer, BitmapInfoHeader.biSizeImage, AVIIF_KEYFRAME, nullptr, nullptr); - if(hr != 0) { + const HRESULT hr = AVIStreamWrite(Stream, Counter, 1, BitmapPointer, + BitmapInfoHeader.biSizeImage, AVIIF_KEYFRAME, nullptr, nullptr); + if (FAILED(hr)) { + LastError = hr; + Ready = false; char buf[256]; - sprintf(buf, "avi write error %lx/%ld\n", hr, hr); + std::snprintf(buf, sizeof(buf), "avi write error %lx/%ld\n", + static_cast(hr), static_cast(hr)); OutputDebugStringA(buf); + return false; } + + ++Counter; + return true; } -void FrameGrabClass::GrabRawFrame(void * /*BitmapPointer*/) +bool FrameGrabClass::GrabRawFrame(void * /*BitmapPointer*/) { - + return false; } - void FrameGrabClass::ConvertGrab(void *BitmapPointer) { + if (!Ready || BitmapPointer == nullptr || Bitmap == nullptr) { + return; + } + ConvertFrame(BitmapPointer); - Grab( Bitmap ); + Grab(Bitmap); } - -void FrameGrabClass::Grab(void *BitmapPointer) +bool FrameGrabClass::Grab(void *BitmapPointer) { - if(Mode == AVI) - GrabAVI(BitmapPointer); - else - GrabRawFrame(BitmapPointer); + if (Mode == AVI) { + return GrabAVI(BitmapPointer); + } + + return GrabRawFrame(BitmapPointer); } +unsigned int FrameGrabClass::Calculate_Row_Stride(int width, int bitdepth) +{ + if (width <= 0 || bitdepth <= 0) { + return 0; + } + + const unsigned long long bit_count = + static_cast(width) * static_cast(bitdepth); + const unsigned long long stride = ((bit_count + 31ULL) & ~31ULL) / 8ULL; + if (stride > std::numeric_limits::max()) { + return 0; + } + + return static_cast(stride); +} void FrameGrabClass::ConvertFrame(void *BitmapPointer) { - int width = BitmapInfoHeader.biWidth; int height = BitmapInfoHeader.biHeight; - int *image = (int *) BitmapPointer; + int *image = static_cast(BitmapPointer); - // copy the data, doing a vertical flip & byte re-ordering of the pixel longwords + // Copy the data, doing a vertical flip and byte re-ordering of the pixel longwords. int y = height; - while(y--) { + while (y--) { int x = width; int yoffset = y * width; int yoffset2 = (height - y) * width; - while(x--) { + while (x--) { int *source = &image[yoffset + x]; int *dest = &Bitmap[yoffset2 + x]; *dest = *source; - unsigned char *c = (unsigned char *) dest; + unsigned char *c = reinterpret_cast(dest); c[3] = c[0]; c[0] = c[2]; c[2] = c[3]; diff --git a/Code/ww3d2/framgrab.h b/Code/ww3d2/framgrab.h index cdaf307f7..b7e3e6b9a 100644 --- a/Code/ww3d2/framgrab.h +++ b/Code/ww3d2/framgrab.h @@ -76,20 +76,33 @@ class FrameGrabClass virtual ~FrameGrabClass(); void ConvertGrab(void *BitmapPointer); - void Grab(void *BitmapPointer); + bool Grab(void *BitmapPointer); int * GetBuffer() { return Bitmap; } float GetFrameRate() { return FrameRate; } + bool IsReady() const { return Ready; } + HRESULT GetLastError() const { return LastError; } + int GetWidth() const { return Width; } + int GetHeight() const { return Height; } + unsigned int GetBufferStride() const { return BufferStride; } + unsigned int GetBufferSize() const { return BitmapInfoHeader.biSizeImage; } + + static unsigned int Calculate_Row_Stride(int width, int bitdepth); protected: - const char *Filename; float FrameRate; MODE Mode; int Counter; // used for incrementing filename cunter, etc. - - void GrabAVI(void *BitmapPointer); - void GrabRawFrame(void *BitmapPointer); + int Width; + int Height; + unsigned int BufferStride; + bool AVIInitialized; + bool Ready; + HRESULT LastError; + + bool GrabAVI(void *BitmapPointer); + bool GrabRawFrame(void *BitmapPointer); // avi settings PAVIFILE AVIFile; diff --git a/Code/ww3d2/hlod.cpp b/Code/ww3d2/hlod.cpp index 0894ea044..f20f845ef 100644 --- a/Code/ww3d2/hlod.cpp +++ b/Code/ww3d2/hlod.cpp @@ -451,7 +451,9 @@ WW3DErrorType HLodDefClass::Save(ChunkSaveClass & csave) } // Close the aggregate chunk - csave.End_Chunk (); + if (!csave.End_Chunk ()) { + ret_val = WW3D_ERROR_SAVE_FAILED; + } } // Return the WW3DErrorType return code @@ -500,7 +502,9 @@ WW3DErrorType HLodDefClass::Save_Header(ChunkSaveClass &csave) } // End the header chunk - csave.End_Chunk (); + if (!csave.End_Chunk ()) { + ret_val = WW3D_ERROR_SAVE_FAILED; + } } // Return the WW3DErrorType return code @@ -552,12 +556,17 @@ WW3DErrorType HLodDefClass::Save_Lod_Array(ChunkSaveClass &csave) *=============================================================================================*/ WW3DErrorType HLodDefClass::Save_Aggregate_Array(ChunkSaveClass & csave) { - if (Aggregates.ModelCount > 0) { - csave.Begin_Chunk(W3D_CHUNK_HLOD_AGGREGATE_ARRAY); - Aggregates.Save_W3D(csave); - csave.End_Chunk(); + if (Aggregates.ModelCount <= 0) { + return WW3D_ERROR_OK; } - return WW3D_ERROR_OK; + + if (!csave.Begin_Chunk(W3D_CHUNK_HLOD_AGGREGATE_ARRAY)) { + return WW3D_ERROR_SAVE_FAILED; + } + + const bool saved = Aggregates.Save_W3D(csave); + const bool ended = csave.End_Chunk(); + return saved && ended ? WW3D_ERROR_OK : WW3D_ERROR_SAVE_FAILED; } @@ -863,7 +872,7 @@ bool HLodDefClass::SubObjectArrayClass::Save_W3D(ChunkSaveClass &csave) ret_val = (csave.Write (&header, sizeof (header)) == sizeof (header)); // End the header chunk - csave.End_Chunk (); + ret_val = csave.End_Chunk () && ret_val; } if (ret_val) { @@ -888,13 +897,13 @@ bool HLodDefClass::SubObjectArrayClass::Save_W3D(ChunkSaveClass &csave) ret_val &= (csave.Write (&info, sizeof (info)) == sizeof (info)); // End the sub-obj chunk - csave.End_Chunk (); + ret_val = csave.End_Chunk () && ret_val; } } } // End the HLOD-Array chunk - csave.End_Chunk (); + ret_val = csave.End_Chunk () && ret_val; } // Return the true/false result code @@ -3630,4 +3639,3 @@ void HLodClass::Set_Hidden(int onoff) Animatable3DObjClass::Set_Hidden(onoff); return ; } - diff --git a/Code/ww3d2/mesh.cpp b/Code/ww3d2/mesh.cpp index e88c3910f..765936f52 100644 --- a/Code/ww3d2/mesh.cpp +++ b/Code/ww3d2/mesh.cpp @@ -516,7 +516,23 @@ void MeshClass::Scale(float scalex, float scaley, float scalez) void MeshClass::Get_Deformed_Vertices(Vector3 *dst_vert, Vector3 *dst_norm) { WWASSERT(Model->Get_Flag(MeshGeometryClass::SKIN)); - Model->get_deformed_vertices(dst_vert,dst_norm,Container->Get_HTree()); + const HTreeClass *htree = (Container != nullptr) ? Container->Get_HTree() : nullptr; + if (htree == nullptr) { + const int vertex_count = Model->Get_Vertex_Count(); + const Vector3 *src_vert = Model->Get_Vertex_Array(); + const Vector3 *src_norm = Model->Get_Vertex_Normal_Array(); + const Matrix3D &world = Get_Transform(); + + for (int vi = 0; vi < vertex_count; ++vi) { + Matrix3D::Transform_Vector(world, src_vert[vi], &dst_vert[vi]); + if (dst_norm) { + Matrix3D::Rotate_Vector(world, src_norm[vi], &dst_norm[vi]); + } + } + return; + } + + Model->get_deformed_vertices(dst_vert,dst_norm,htree); } @@ -535,10 +551,19 @@ void MeshClass::Get_Deformed_Vertices(Vector3 *dst_vert, Vector3 *dst_norm) void MeshClass::Get_Deformed_Vertices(Vector3 *dst_vert) { WWASSERT(Model->Get_Flag(MeshGeometryClass::SKIN)); - WWASSERT(Container != nullptr); - WWASSERT(Container->Get_HTree() != nullptr); + const HTreeClass *htree = (Container != nullptr) ? Container->Get_HTree() : nullptr; + if (htree == nullptr) { + const int vertex_count = Model->Get_Vertex_Count(); + const Vector3 *src_vert = Model->Get_Vertex_Array(); + const Matrix3D &world = Get_Transform(); + + for (int vi = 0; vi < vertex_count; ++vi) { + Matrix3D::Transform_Vector(world, src_vert[vi], &dst_vert[vi]); + } + return; + } - Model->get_deformed_vertices(dst_vert,Container->Get_HTree()); + Model->get_deformed_vertices(dst_vert,htree); } void MeshClass::Compose_Deformed_Vertex_Buffer( @@ -548,7 +573,45 @@ void MeshClass::Compose_Deformed_Vertex_Buffer( const unsigned* diffuse) { WWASSERT(Model->Get_Flag(MeshGeometryClass::SKIN)); - Model->compose_deformed_vertex_buffer(verts,uv0,uv1,diffuse,Container->Get_HTree()); + const HTreeClass *htree = (Container != nullptr) ? Container->Get_HTree() : nullptr; + if (htree == nullptr) { + const int vertex_count = Model->Get_Vertex_Count(); + const Vector3 *src_vert = Model->Get_Vertex_Array(); + const Vector3 *src_norm = Model->Get_Vertex_Normal_Array(); + const Matrix3D &world = Get_Transform(); + + for (int vi = 0; vi < vertex_count; ++vi) { + Vector3 pos; + Vector3 norm; + Matrix3D::Transform_Vector(world, src_vert[vi], &pos); + Matrix3D::Rotate_Vector(world, src_norm[vi], &norm); + + verts[vi].x = pos[0]; + verts[vi].y = pos[1]; + verts[vi].z = pos[2]; + verts[vi].nx = norm[0]; + verts[vi].ny = norm[1]; + verts[vi].nz = norm[2]; + verts[vi].diffuse = diffuse ? diffuse[vi] : 0; + if (uv0) { + verts[vi].u1 = uv0[vi][0]; + verts[vi].v1 = uv0[vi][1]; + } else { + verts[vi].u1 = 0.0f; + verts[vi].v1 = 0.0f; + } + if (uv1) { + verts[vi].u2 = uv1[vi][0]; + verts[vi].v2 = uv1[vi][1]; + } else { + verts[vi].u2 = 0.0f; + verts[vi].v2 = 0.0f; + } + } + return; + } + + Model->compose_deformed_vertex_buffer(verts,uv0,uv1,diffuse,htree); } /*********************************************************************************************** @@ -1604,4 +1667,3 @@ void MeshClass::Load_User_Lighting (ChunkLoadClass & cload) - diff --git a/Code/ww3d2/part_ldr.cpp b/Code/ww3d2/part_ldr.cpp index d50aa85e3..efd6b1114 100644 --- a/Code/ww3d2/part_ldr.cpp +++ b/Code/ww3d2/part_ldr.cpp @@ -1156,7 +1156,9 @@ ParticleEmitterDefClass::Save_W3D (ChunkSaveClass &chunk_save) } // Close the emitter chunk - chunk_save.End_Chunk (); + if (!chunk_save.End_Chunk ()) { + ret_val = WW3D_ERROR_SAVE_FAILED; + } } // Return the WW3DErrorType return code @@ -1191,7 +1193,9 @@ ParticleEmitterDefClass::Save_Header (ChunkSaveClass &chunk_save) } // End the header chunk - chunk_save.End_Chunk (); + if (!chunk_save.End_Chunk ()) { + ret_val = WW3D_ERROR_SAVE_FAILED; + } } // Return the WW3DErrorType return code @@ -1241,7 +1245,9 @@ ParticleEmitterDefClass::Save_User_Data (ChunkSaveClass &chunk_save) } // End the user information chunk - chunk_save.End_Chunk (); + if (!chunk_save.End_Chunk ()) { + ret_val = WW3D_ERROR_SAVE_FAILED; + } } // Return the WW3DErrorType return code @@ -1270,7 +1276,9 @@ ParticleEmitterDefClass::Save_Info (ChunkSaveClass &chunk_save) } // End the settings chunk - chunk_save.End_Chunk (); + if (!chunk_save.End_Chunk ()) { + ret_val = WW3D_ERROR_SAVE_FAILED; + } } // Return the WW3DErrorType return code @@ -1299,7 +1307,9 @@ ParticleEmitterDefClass::Save_InfoV2 (ChunkSaveClass &chunk_save) } // End the settings chunk - chunk_save.End_Chunk (); + if (!chunk_save.End_Chunk ()) { + ret_val = WW3D_ERROR_SAVE_FAILED; + } } // Return the WW3DErrorType return code @@ -1349,7 +1359,9 @@ ParticleEmitterDefClass::Save_Props (ChunkSaveClass &chunk_save) } // End the settings chunk - chunk_save.End_Chunk (); + if (!chunk_save.End_Chunk ()) { + ret_val = WW3D_ERROR_SAVE_FAILED; + } } // Return the WW3DErrorType return code @@ -1487,7 +1499,9 @@ ParticleEmitterDefClass::Save_Line_Properties (ChunkSaveClass &chunk_save) } // End the chunk - chunk_save.End_Chunk (); + if (!chunk_save.End_Chunk ()) { + ret_val = WW3D_ERROR_SAVE_FAILED; + } } // Return the WW3DErrorType return code @@ -1510,20 +1524,21 @@ ParticleEmitterDefClass::Save_Rotation_Keyframes (ChunkSaveClass & chunk_save) if (chunk_save.Begin_Chunk (W3D_CHUNK_EMITTER_ROTATION_KEYFRAMES) == true) { // Write the header - W3dEmitterRotationHeaderStruct header; + W3dEmitterRotationHeaderStruct header = {}; header.KeyframeCount = m_RotationKeyframes.NumKeyFrames; header.Random = m_RotationKeyframes.Rand; header.OrientationRandom = m_InitialOrientationRandom; - chunk_save.Write (&header, sizeof (W3dEmitterRotationHeaderStruct)); + bool success = + (chunk_save.Write (&header, sizeof (W3dEmitterRotationHeaderStruct)) == + sizeof (W3dEmitterRotationHeaderStruct)); // Write the keyframes - bool success = true; W3dEmitterRotationKeyframeStruct key; // Write the start keyframe key.Time = 0; key.Rotation = m_RotationKeyframes.Start; - chunk_save.Write (&key, sizeof (key)); + success = (chunk_save.Write (&key, sizeof (key)) == sizeof (key)) && success; // Write the remaining keyframes for (unsigned int index = 0; (index < header.KeyframeCount) && success; index ++) { @@ -1535,7 +1550,9 @@ ParticleEmitterDefClass::Save_Rotation_Keyframes (ChunkSaveClass & chunk_save) ret_val = success ? WW3D_ERROR_OK : WW3D_ERROR_SAVE_FAILED; // End the chunk - chunk_save.End_Chunk (); + if (!chunk_save.End_Chunk ()) { + ret_val = WW3D_ERROR_SAVE_FAILED; + } } // Return the WW3DErrorType return code @@ -1559,19 +1576,20 @@ ParticleEmitterDefClass::Save_Frame_Keyframes (ChunkSaveClass & chunk_save) if (chunk_save.Begin_Chunk (W3D_CHUNK_EMITTER_FRAME_KEYFRAMES) == true) { // Write the header - W3dEmitterFrameHeaderStruct header; + W3dEmitterFrameHeaderStruct header = {}; header.KeyframeCount = m_FrameKeyframes.NumKeyFrames; header.Random = m_FrameKeyframes.Rand; - chunk_save.Write (&header, sizeof (W3dEmitterFrameHeaderStruct)); + bool success = + (chunk_save.Write (&header, sizeof (W3dEmitterFrameHeaderStruct)) == + sizeof (W3dEmitterFrameHeaderStruct)); // Write the keyframes - bool success = true; W3dEmitterFrameKeyframeStruct key; // Write the start keyframe key.Time = 0; key.Frame = m_FrameKeyframes.Start; - chunk_save.Write (&key, sizeof (key)); + success = (chunk_save.Write (&key, sizeof (key)) == sizeof (key)) && success; // Write the remaining keyframes for (unsigned int index = 0; (index < header.KeyframeCount) && success; index ++) { @@ -1583,7 +1601,9 @@ ParticleEmitterDefClass::Save_Frame_Keyframes (ChunkSaveClass & chunk_save) ret_val = success ? WW3D_ERROR_OK : WW3D_ERROR_SAVE_FAILED; // End the chunk - chunk_save.End_Chunk (); + if (!chunk_save.End_Chunk ()) { + ret_val = WW3D_ERROR_SAVE_FAILED; + } } // Return the WW3DErrorType return code @@ -1606,19 +1626,20 @@ ParticleEmitterDefClass::Save_Blur_Time_Keyframes (ChunkSaveClass & chunk_save) if (chunk_save.Begin_Chunk (W3D_CHUNK_EMITTER_BLUR_TIME_KEYFRAMES) == true) { // Write the header - W3dEmitterBlurTimeHeaderStruct header; + W3dEmitterBlurTimeHeaderStruct header = {}; header.KeyframeCount = m_BlurTimeKeyframes.NumKeyFrames; header.Random = m_BlurTimeKeyframes.Rand; - chunk_save.Write (&header, sizeof (W3dEmitterBlurTimeHeaderStruct)); + bool success = + (chunk_save.Write (&header, sizeof (W3dEmitterBlurTimeHeaderStruct)) == + sizeof (W3dEmitterBlurTimeHeaderStruct)); // Write the keyframes - bool success = true; W3dEmitterBlurTimeKeyframeStruct key; // Write the start keyframe key.Time = 0; key.BlurTime = m_BlurTimeKeyframes.Start; - chunk_save.Write (&key, sizeof (key)); + success = (chunk_save.Write (&key, sizeof (key)) == sizeof (key)) && success; // Write the remaining keyframes for (unsigned int index = 0; (index < header.KeyframeCount) && success; index ++) { @@ -1630,7 +1651,9 @@ ParticleEmitterDefClass::Save_Blur_Time_Keyframes (ChunkSaveClass & chunk_save) ret_val = success ? WW3D_ERROR_OK : WW3D_ERROR_SAVE_FAILED; // End the chunk - chunk_save.End_Chunk (); + if (!chunk_save.End_Chunk ()) { + ret_val = WW3D_ERROR_SAVE_FAILED; + } } // Return the WW3DErrorType return code diff --git a/Code/ww3d2/prim_anim.h b/Code/ww3d2/prim_anim.h index 27950bc00..644f6b32a 100644 --- a/Code/ww3d2/prim_anim.h +++ b/Code/ww3d2/prim_anim.h @@ -116,7 +116,7 @@ class PrimitiveAnimationChannelClass void Delete_Key (int index); void Reset (void); - virtual void Save (ChunkSaveClass &csave); + virtual bool Save (ChunkSaveClass &csave); virtual void Load (ChunkLoadClass &cload); protected: @@ -271,22 +271,24 @@ PrimitiveAnimationChannelClass::operator= (const PrimitiveAnimationChannelCla ///////////////////////////////////////////////////////////////////// // Save ///////////////////////////////////////////////////////////////////// -template void +template bool PrimitiveAnimationChannelClass::Save (ChunkSaveClass &csave) { - csave.Begin_Chunk (CHUNKID_VARIABLES); + if (!csave.Begin_Chunk (CHUNKID_VARIABLES)) { + return false; + } // // Save each key // - for (int index = 0; index < m_Data.Count (); index ++) { + bool saved = true; + for (int index = 0; (index < m_Data.Count ()) && saved; index ++) { KeyClass &value = m_Data[index]; - WRITE_MICRO_CHUNK (csave, VARID_KEY, value); + saved = csave.Write_Micro_Chunk (VARID_KEY, &value, sizeof (value)); } - csave.End_Chunk (); - - return ; + const bool ended = csave.End_Chunk (); + return saved && ended && !csave.Has_Write_Error (); } ///////////////////////////////////////////////////////////////////// diff --git a/Code/ww3d2/ringobj.cpp b/Code/ww3d2/ringobj.cpp index 21c93e060..5042a699d 100644 --- a/Code/ww3d2/ringobj.cpp +++ b/Code/ww3d2/ringobj.cpp @@ -1184,38 +1184,52 @@ bool RingPrototypeClass::Load (ChunkLoadClass &cload) bool RingPrototypeClass::Save (ChunkSaveClass &csave) { - csave.Begin_Chunk (W3D_CHUNK_RING); + const auto save_chunk = [&csave](uint32 chunk_id, const auto &save_contents) { + const bool began = csave.Begin_Chunk (chunk_id); + if (!began) { + return false; + } - csave.Begin_Chunk (CHUNKID_RING_DEF); - csave.Write (&Definition, sizeof (Definition)); - csave.End_Chunk (); + const bool saved = save_contents (); + const bool ended = csave.End_Chunk (); + return saved && ended; + }; - if (ColorChannel.Get_Key_Count () > 0) { - csave.Begin_Chunk (CHUNKID_COLOR_CHANNEL); - ColorChannel.Save (csave); - csave.End_Chunk (); - } + const bool began_ring = csave.Begin_Chunk (W3D_CHUNK_RING); + if (!began_ring) { + return false; + } - if (AlphaChannel.Get_Key_Count () > 0) { - csave.Begin_Chunk (CHUNKID_ALPHA_CHANNEL); - AlphaChannel.Save (csave); - csave.End_Chunk (); - } + bool ok = save_chunk (CHUNKID_RING_DEF, [this, &csave]() { + return csave.Write (&Definition, sizeof (Definition)) == sizeof (Definition); + }); - if (InnerScaleChannel.Get_Key_Count () > 0) { - csave.Begin_Chunk (CHUNKID_INNER_SCALE_CHANNEL); - InnerScaleChannel.Save (csave); - csave.End_Chunk (); - } + if (ColorChannel.Get_Key_Count () > 0) { + ok = save_chunk (CHUNKID_COLOR_CHANNEL, [this, &csave]() { + return ColorChannel.Save (csave); + }) && ok; + } - if (OuterScaleChannel.Get_Key_Count () > 0) { - csave.Begin_Chunk (CHUNKID_OUTER_SCALE_CHANNEL); - OuterScaleChannel.Save (csave); - csave.End_Chunk (); - } + if (AlphaChannel.Get_Key_Count () > 0) { + ok = save_chunk (CHUNKID_ALPHA_CHANNEL, [this, &csave]() { + return AlphaChannel.Save (csave); + }) && ok; + } - csave.End_Chunk (); - return true; + if (InnerScaleChannel.Get_Key_Count () > 0) { + ok = save_chunk (CHUNKID_INNER_SCALE_CHANNEL, [this, &csave]() { + return InnerScaleChannel.Save (csave); + }) && ok; + } + + if (OuterScaleChannel.Get_Key_Count () > 0) { + ok = save_chunk (CHUNKID_OUTER_SCALE_CHANNEL, [this, &csave]() { + return OuterScaleChannel.Save (csave); + }) && ok; + } + + const bool ended_ring = csave.End_Chunk (); + return ok && ended_ring && !csave.Has_Write_Error (); } RenderObjClass * RingPrototypeClass::Create(void) diff --git a/Code/ww3d2/soundrobj.cpp b/Code/ww3d2/soundrobj.cpp index 14de9e27d..e80eef531 100644 --- a/Code/ww3d2/soundrobj.cpp +++ b/Code/ww3d2/soundrobj.cpp @@ -581,13 +581,14 @@ SoundRenderObjDefClass::Save_W3D (ChunkSaveClass &csave) // // Attempt to save the different sections of the aggregate definition // - if ((Write_Header (csave) == WW3D_ERROR_OK) && - (Write_Definition (csave) == WW3D_ERROR_OK)) - { + const bool wrote_header = Write_Header (csave) == WW3D_ERROR_OK; + const bool wrote_definition = wrote_header && + (Write_Definition (csave) == WW3D_ERROR_OK); + const bool ended_chunk = csave.End_Chunk (); + + if (wrote_header && wrote_definition && ended_chunk && !csave.Has_Write_Error ()) { retval = WW3D_ERROR_OK; } - - csave.End_Chunk (); } return retval; @@ -689,12 +690,13 @@ SoundRenderObjDefClass::Write_Header (ChunkSaveClass &csave) // // Write the header out to the chunk // - if (csave.Write (&header, sizeof (header)) == sizeof (header)) { + const bool wrote_header = csave.Write (&header, sizeof (header)) == sizeof (header); + + // End the header chunk even when its payload failed so the save stack stays balanced. + const bool ended_chunk = csave.End_Chunk (); + if (wrote_header && ended_chunk && !csave.Has_Write_Error ()) { retval = WW3D_ERROR_OK; } - - // End the header chunk - csave.End_Chunk (); } return retval; @@ -715,10 +717,11 @@ SoundRenderObjDefClass::Write_Definition (ChunkSaveClass &csave) // Save the definition to its own chunk // if (csave.Begin_Chunk (W3D_CHUNK_SOUNDROBJ_DEFINITION) == true) { - if (Definition.Save (csave)) { + const bool wrote_definition = Definition.Save (csave); + const bool ended_chunk = csave.End_Chunk (); + if (wrote_definition && ended_chunk && !csave.Has_Write_Error ()) { retval = WW3D_ERROR_OK; } - csave.End_Chunk (); } return retval; @@ -765,4 +768,3 @@ SoundRenderObjLoaderClass::Load_W3D (ChunkLoadClass &cload) return prototype; } - diff --git a/Code/ww3d2/soundrobj.h b/Code/ww3d2/soundrobj.h index d81d096b3..35363a0a4 100644 --- a/Code/ww3d2/soundrobj.h +++ b/Code/ww3d2/soundrobj.h @@ -181,6 +181,7 @@ class SoundRenderObjDefClass : public RefCountClass const char * Get_Name (void) const { return Name; } void Set_Name (const char *name) { Name = name; } SoundRenderObjDefClass * Clone (void) const { return NEW_REF( SoundRenderObjDefClass, (*this) ); } + const AudibleSoundDefinitionClass *Peek_Sound_Definition (void) const { return &Definition; } // // Initialization @@ -272,4 +273,3 @@ extern SoundRenderObjLoaderClass _SoundRenderObjLoader; #endif //__SOUNDROBJ_H - diff --git a/Code/ww3d2/sphereobj.cpp b/Code/ww3d2/sphereobj.cpp index 994082129..5e40da7ec 100644 --- a/Code/ww3d2/sphereobj.cpp +++ b/Code/ww3d2/sphereobj.cpp @@ -1124,39 +1124,52 @@ bool SpherePrototypeClass::Load (ChunkLoadClass &cload) bool SpherePrototypeClass::Save (ChunkSaveClass &csave) { - csave.Begin_Chunk (W3D_CHUNK_SPHERE); + const auto save_chunk = [&csave](uint32 chunk_id, const auto &save_contents) { + const bool began = csave.Begin_Chunk (chunk_id); + if (!began) { + return false; + } - csave.Begin_Chunk (CHUNKID_SPHERE_DEF); - csave.Write (&Definition, sizeof (Definition)); - csave.End_Chunk (); + const bool saved = save_contents (); + const bool ended = csave.End_Chunk (); + return saved && ended; + }; - if (ColorChannel.Get_Key_Count () > 0) { - csave.Begin_Chunk (CHUNKID_COLOR_CHANNEL); - ColorChannel.Save (csave); - csave.End_Chunk (); - } + const bool began_sphere = csave.Begin_Chunk (W3D_CHUNK_SPHERE); + if (!began_sphere) { + return false; + } - if (AlphaChannel.Get_Key_Count () > 0) { - csave.Begin_Chunk (CHUNKID_ALPHA_CHANNEL); - AlphaChannel.Save (csave); - csave.End_Chunk (); - } + bool ok = save_chunk (CHUNKID_SPHERE_DEF, [this, &csave]() { + return csave.Write (&Definition, sizeof (Definition)) == sizeof (Definition); + }); + if (ColorChannel.Get_Key_Count () > 0) { + ok = save_chunk (CHUNKID_COLOR_CHANNEL, [this, &csave]() { + return ColorChannel.Save (csave); + }) && ok; + } - if (ScaleChannel.Get_Key_Count () > 0) { - csave.Begin_Chunk (CHUNKID_SCALE_CHANNEL); - ScaleChannel.Save (csave); - csave.End_Chunk (); - } + if (AlphaChannel.Get_Key_Count () > 0) { + ok = save_chunk (CHUNKID_ALPHA_CHANNEL, [this, &csave]() { + return AlphaChannel.Save (csave); + }) && ok; + } - if (VectorChannel.Get_Key_Count () > 0) { - csave.Begin_Chunk (CHUNKID_VECTOR_CHANNEL); - VectorChannel.Save (csave); - csave.End_Chunk (); - } + if (ScaleChannel.Get_Key_Count () > 0) { + ok = save_chunk (CHUNKID_SCALE_CHANNEL, [this, &csave]() { + return ScaleChannel.Save (csave); + }) && ok; + } - csave.End_Chunk (); - return true; + if (VectorChannel.Get_Key_Count () > 0) { + ok = save_chunk (CHUNKID_VECTOR_CHANNEL, [this, &csave]() { + return VectorChannel.Save (csave); + }) && ok; + } + + const bool ended_sphere = csave.End_Chunk (); + return ok && ended_sphere && !csave.Has_Write_Error (); } const char * SpherePrototypeClass::Get_Name(void) const @@ -1614,4 +1627,3 @@ void SphereMeshClass::Free(void) } // EOF - sphereobj.cpp - diff --git a/Code/ww3d2/tests/FrameGrabTests.cpp b/Code/ww3d2/tests/FrameGrabTests.cpp new file mode 100644 index 000000000..65471e1d6 --- /dev/null +++ b/Code/ww3d2/tests/FrameGrabTests.cpp @@ -0,0 +1,79 @@ +/* +** Command & Conquer Renegade(tm) +** Copyright 2025 Electronic Arts Inc. +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation, either version 3 of the License, or +** (at your option) any later version. +*/ + +#include "framgrab.h" +#include "ww3d.h" + +#include +#include +#include + +static_assert(std::is_same_v); +static_assert(std::is_same_v); +static_assert(std::is_same_v); +static_assert(std::is_same_v); +static_assert(std::is_same_v); + +namespace +{ +bool CheckStride(int width, int bitdepth, unsigned int expected) +{ + const unsigned int actual = FrameGrabClass::Calculate_Row_Stride(width, bitdepth); + if (actual == expected) { + return true; + } + + std::fprintf(stderr, + "row stride for %dx%d was %u, expected %u\n", + width, + bitdepth, + actual, + expected); + return false; +} +} + +int main() +{ + bool passed = true; + passed &= CheckStride(0, 24, 0); + passed &= CheckStride(1, 24, 4); + passed &= CheckStride(2, 24, 8); + passed &= CheckStride(3, 24, 12); + passed &= CheckStride(4, 24, 12); + passed &= CheckStride(955, 24, 2868); + passed &= CheckStride(std::numeric_limits::max(), 32, 0); + + FrameGrabClass invalid_dimensions("unused", FrameGrabClass::AVI, 0, 8, 24, 30.0f); + if (invalid_dimensions.IsReady() || invalid_dimensions.GetBuffer() != nullptr || + invalid_dimensions.GetBufferSize() != 0 || + invalid_dimensions.GetLastError() != E_INVALIDARG) { + std::fprintf(stderr, "invalid capture dimensions did not leave a safe, failed writer\n"); + passed = false; + } + + FrameGrabClass invalid_path("?:\\OpenW3D\\FrameGrabFailure", + FrameGrabClass::AVI, + 4, + 2, + 24, + 30.0f); + if (invalid_path.IsReady() || invalid_path.GetBuffer() != nullptr || + !FAILED(invalid_path.GetLastError()) || invalid_path.Grab(nullptr)) { + std::fprintf(stderr, "AVI open failure did not remain observable and safe\n"); + passed = false; + } + invalid_path.ConvertGrab(nullptr); + + return passed ? 0 : 1; +} diff --git a/Code/ww3d2/tests/ScreenshotApiTests.cpp b/Code/ww3d2/tests/ScreenshotApiTests.cpp new file mode 100644 index 000000000..1c91891c9 --- /dev/null +++ b/Code/ww3d2/tests/ScreenshotApiTests.cpp @@ -0,0 +1,28 @@ +/* +** Command & Conquer Renegade(tm) +** Copyright 2025 Electronic Arts Inc. +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU General Public License as published by +** the Free Software Foundation, either version 3 of the License, or +** (at your option) any later version. +*/ + +#include "ww3d.h" + +#include + +namespace +{ +using LegacyScreenshotFunction = void (*)(const char *); +using BackBufferScreenshotFunction = int (*)(const char *); + +static_assert(std::is_same_v); +static_assert(std::is_same_v); +} + +int main() +{ + return 0; +} diff --git a/Code/ww3d2/ww3d.cpp b/Code/ww3d2/ww3d.cpp index 079cafd5b..3fc5b0399 100644 --- a/Code/ww3d2/ww3d.cpp +++ b/Code/ww3d2/ww3d.cpp @@ -97,6 +97,7 @@ #include "statistics.h" #include "pointgr.h" #include "ffactory.h" +#include "wwstring.h" #include "ini.h" #include "dazzle.h" #include "meshmdl.h" @@ -106,6 +107,9 @@ #include "rddesc.h" #include "vector3i.h" #include +#ifdef _WIN32 +#include +#endif #include "dx8wrapper.h" #include "TARGA.H" #include "sortingrenderer.h" @@ -1238,8 +1242,37 @@ void WW3D::Normalize_Coordinates(int x, int y, float &fx, float &fy) } +namespace +{ +int Make_Screen_Shot_Filename(const char *filename_base, StringClass &filename) +{ + if (filename_base == nullptr || filename_base[0] == '\0') { + return 0; + } + + static int frame_number = 1; + int screenshot_number = 0; + bool done = false; + while (!done) { + screenshot_number = frame_number++; + filename.Format("%s%.2d.tga", filename_base, screenshot_number); + FileClass *file = _TheFileFactory->Get_File(filename.Peek_Buffer()); + if (file != nullptr) { + file->Open(); + done = !file->Is_Available(); + _TheFileFactory->Return_File(file); + } else { + done = true; + } + } + + return screenshot_number; +} +} + + /*********************************************************************************************** - * WW3D::Make_Screen_Shot -- saves a screenshot with the given base filename * + * WW3D::Make_Screen_Shot -- saves the window's front-buffer image * * * * INPUT: * * * @@ -1253,86 +1286,96 @@ void WW3D::Normalize_Coordinates(int x, int y, float &fx, float &fy) *=============================================================================================*/ void WW3D::Make_Screen_Shot( const char * filename_base ) { - WWASSERT(!IsRendering); - char filename[80]; - - static int frame_number = 1; - - bool done = false; - while (!done) { - sprintf( filename, "%s%.2d.tga", filename_base, frame_number++); - FileClass*file=_TheFileFactory->Get_File( filename ); - if ( file ) { - file->Open(); - done = !file->Is_Available(); - _TheFileFactory->Return_File( file ); - } else { - done = true; - } + StringClass filename; + if (Make_Screen_Shot_Filename(filename_base, filename) == 0 || _Hwnd == nullptr) { + return; } - WWDEBUG_SAY(( "Creating Screen Shot %s\n", filename )); - - // Lock front buffer and copy - - IDirect3DSurface9 *fb; - fb=DX8Wrapper::_Get_DX8_Front_Buffer(); - D3DSURFACE_DESC desc; - fb->GetDesc(&desc); + WWDEBUG_SAY(( "Creating Screen Shot %s\n", filename.Peek_Buffer() )); RECT bounds; - GetWindowRect(_Hwnd,&bounds); - - D3DLOCKED_RECT lrect; - - DX8_ErrorCode(fb->LockRect(&lrect,&bounds,D3DLOCK_READONLY)); - - unsigned int x,y,index,index2,width,height; + if (!GetWindowRect(_Hwnd, &bounds)) { + return; + } - width=bounds.right-bounds.left; - height=bounds.bottom-bounds.top; + // Preserve the legacy front-buffer capture path for existing callers. + IDirect3DSurface9 *front_buffer = DX8Wrapper::_Get_DX8_Front_Buffer(); + if (front_buffer == nullptr) { + return; + } - char *image=new char[3*width*height]; + D3DLOCKED_RECT locked; + if (FAILED(front_buffer->LockRect(&locked, &bounds, D3DLOCK_READONLY))) { + front_buffer->Release(); + return; + } - for (y=0; y(bounds.right - bounds.left); + const unsigned int height = static_cast(bounds.bottom - bounds.top); + char *image = new char[3 * width * height]; + for (unsigned int y = 0; y < height; ++y) { + for (unsigned int x = 0; x < width; ++x) { + const unsigned int image_index = 3 * (x + y * width); + const unsigned int buffer_index = y * locked.Pitch + 4 * x; + image[image_index] = *(static_cast(locked.pBits) + buffer_index + 2); + image[image_index + 1] = *(static_cast(locked.pBits) + buffer_index + 1); + image[image_index + 2] = *(static_cast(locked.pBits) + buffer_index); } } - fb->Release(); + front_buffer->UnlockRect(); + front_buffer->Release(); + + Targa target; + memset(&target.Header, 0, sizeof(target.Header)); + target.Header.Width = static_cast(width); + target.Header.Height = static_cast(height); + target.Header.PixelDepth = 24; + target.Header.ImageType = TGA_TRUECOLOR; + target.SetImage(image); + target.YFlip(); + target.Save(filename.Peek_Buffer(), TGAF_IMAGE, false); + delete [] image; +} - Targa targ; - memset(&targ.Header,0,sizeof(targ.Header)); - targ.Header.Width=short(width); - targ.Header.Height=short(height); - targ.Header.PixelDepth=24; - targ.Header.ImageType=TGA_TRUECOLOR; - targ.SetImage(image); - targ.YFlip(); - FileClass*file=_TheWritingFileFactory->Get_File( filename ); - if ( file ) { - file->Create(); - file->Close(); - _TheWritingFileFactory->Return_File( file ); +/*********************************************************************************************** + * WW3D::Make_Back_Buffer_Screen_Shot -- saves the current render-device back buffer * + *=============================================================================================*/ +int WW3D::Make_Back_Buffer_Screen_Shot( const char * filename_base ) +{ + WWASSERT(!IsRendering); + +#ifdef _WIN32 + StringClass filename; + const int screenshot_number = Make_Screen_Shot_Filename(filename_base, filename); + if (screenshot_number == 0) { + return 0; } - targ.Save(filename,TGAF_IMAGE,false); + WWDEBUG_SAY(( "Creating Back Buffer Screen Shot %s\n", filename.Peek_Buffer() )); - delete [] image; + IDirect3DDevice9 *device = DX8Wrapper::_Get_D3D_Device8(); + if (device == nullptr) { + return 0; + } + IDirect3DSurface9 *back_buffer = nullptr; + if (FAILED(device->GetBackBuffer(0, 0, D3DBACKBUFFER_TYPE_MONO, &back_buffer)) || + back_buffer == nullptr) { + return 0; + } + + const HRESULT save_result = D3DXSaveSurfaceToFileA( + filename.Peek_Buffer(), D3DXIFF_TGA, back_buffer, nullptr, nullptr); + back_buffer->Release(); + return SUCCEEDED(save_result) ? screenshot_number : 0; +#else + (void)filename_base; + return 0; +#endif } @@ -1350,21 +1393,34 @@ void WW3D::Make_Screen_Shot( const char * filename_base ) * 2/26/2001 hy : updated to dx8 * *=============================================================================================*/ void WW3D::Start_Movie_Capture( const char * filename_base, float frame_rate ) +{ + Try_Start_Movie_Capture(filename_base, frame_rate); +} + + +bool WW3D::Try_Start_Movie_Capture( const char * filename_base, float frame_rate ) { #ifdef _WIN32 - if (IsCapturing) { + if (IsCapturing || Movie != nullptr) { Stop_Movie_Capture(); } - WWASSERT( !IsCapturing); - IsCapturing = true; + RecordNextFrame = false; + + if (_Hwnd == nullptr || filename_base == nullptr || filename_base[0] == '\0') { + return false; + } RECT bounds; - GetWindowRect(_Hwnd,&bounds); + if (!GetWindowRect(_Hwnd, &bounds)) { + return false; + } int height=bounds.bottom-bounds.top; int width=bounds.right-bounds.left; int depth=24; - WWASSERT( Movie == nullptr); + if (width <= 0 || height <= 0) { + return false; + } if (frame_rate == 0.0f) { frame_rate = 1.0f; @@ -1373,9 +1429,20 @@ void WW3D::Start_Movie_Capture( const char * filename_base, float frame_rate ) PauseRecord = false; } - Movie = new FrameGrabClass( filename_base, FrameGrabClass::AVI, width, height, depth, frame_rate); + FrameGrabClass *movie = new FrameGrabClass( + filename_base, FrameGrabClass::AVI, width, height, depth, frame_rate); + if (!movie->IsReady()) { + delete movie; + return false; + } + + Movie = movie; + IsCapturing = true; WWDEBUG_SAY(( "Starting Movie %s\n", filename_base )); + return true; +#else + return false; #endif } @@ -1396,10 +1463,12 @@ void WW3D::Stop_Movie_Capture( void ) { #ifdef _WIN32 if (IsCapturing) { - IsCapturing = false; WWDEBUG_SAY(( "Stoping Movie\n" )); + } - WWASSERT( Movie != nullptr); + IsCapturing = false; + RecordNextFrame = false; + if (Movie != nullptr) { delete Movie; Movie = nullptr; } @@ -1515,7 +1584,8 @@ bool WW3D::Is_Movie_Paused() *=============================================================================================*/ bool WW3D::Is_Recording_Next_Frame() { - return (Movie != 0) && (!PauseRecord || RecordNextFrame); + return IsCapturing && Movie != nullptr && Movie->IsReady() && + (!PauseRecord || RecordNextFrame); } @@ -1533,7 +1603,7 @@ bool WW3D::Is_Recording_Next_Frame() *=============================================================================================*/ bool WW3D::Is_Movie_Ready() { - return Movie != 0; + return IsCapturing && Movie != nullptr && Movie->IsReady(); } @@ -1551,51 +1621,264 @@ bool WW3D::Is_Movie_Ready() * 2/26/2001 hy : Updated to dx8 * *=============================================================================================*/ void WW3D::Update_Movie_Capture( void ) +{ + Try_Update_Movie_Capture(); +} + + +bool WW3D::Try_Update_Movie_Capture( void ) { #ifdef _WIN32 - WWASSERT( IsCapturing); + if (!Is_Movie_Ready() || _Hwnd == nullptr) { + return false; + } + WWPROFILE("WW3D::Update_Movie_Capture"); WWDEBUG_SAY(( "Updating\n")); - // Lock front buffer and copy + RECT bounds; + if (!GetWindowRect(_Hwnd, &bounds)) { + Stop_Movie_Capture(); + return false; + } - IDirect3DSurface9 *fb; - fb=DX8Wrapper::_Get_DX8_Front_Buffer(); - D3DSURFACE_DESC desc; - fb->GetDesc(&desc); + const unsigned int width = static_cast(bounds.right - bounds.left); + const unsigned int height = static_cast(bounds.bottom - bounds.top); + if (width != static_cast(Movie->GetWidth()) || + height != static_cast(Movie->GetHeight())) { + Stop_Movie_Capture(); + return false; + } - RECT bounds; - GetWindowRect(_Hwnd,&bounds); + IDirect3DSurface9 *fb = DX8Wrapper::_Get_DX8_Front_Buffer(); + if (fb == nullptr) { + Stop_Movie_Capture(); + return false; + } D3DLOCKED_RECT lrect; + const HRESULT lock_result = fb->LockRect(&lrect, &bounds, D3DLOCK_READONLY); + if (FAILED(lock_result)) { + fb->Release(); + Stop_Movie_Capture(); + return false; + } - DX8_ErrorCode(fb->LockRect(&lrect,&bounds,D3DLOCK_READONLY)); - - unsigned int x,y,index,index2,width,height; + unsigned char *image = reinterpret_cast(Movie->GetBuffer()); + const unsigned int destination_stride = Movie->GetBufferStride(); + const unsigned int pixel_bytes = width * 3; + if (image == nullptr || destination_stride < pixel_bytes) { + fb->UnlockRect(); + fb->Release(); + Stop_Movie_Capture(); + return false; + } - width=bounds.right-bounds.left; - height=bounds.bottom-bounds.top; + for (unsigned int y = 0; y < height; ++y) { + unsigned char *destination = image + (height - y - 1) * destination_stride; + const unsigned char *source = + reinterpret_cast(lrect.pBits) + y * lrect.Pitch; - char *image=(char *)Movie->GetBuffer(); + for (unsigned int x = 0; x < width; ++x) { + destination[3 * x] = source[4 * x]; + destination[3 * x + 1] = source[4 * x + 1]; + destination[3 * x + 2] = source[4 * x + 2]; + } - for (y=0; y pixel_bytes) { + memset(destination + pixel_bytes, 0, destination_stride - pixel_bytes); } } + const HRESULT unlock_result = fb->UnlockRect(); fb->Release(); + if (FAILED(unlock_result)) { + Stop_Movie_Capture(); + return false; + } + + if (!Movie->Grab(image)) { + Stop_Movie_Capture(); + return false; + } + + return true; +#else + return false; +#endif +} + + +/*********************************************************************************************** + * WW3D::Try_Update_Movie_Capture_From_Back_Buffer -- captures an unpresented render frame * + * * + * WARNINGS: * + * Call this after End_Render(false), while the swap-chain back buffer still contains the * + * frame. Unlike the legacy front-buffer path, this is independent of window position and * + * desktop occlusion. * + *=============================================================================================*/ +bool WW3D::Try_Update_Movie_Capture_From_Back_Buffer( void ) +{ +#ifdef _WIN32 + if (!Is_Movie_Ready()) { + return false; + } + + IDirect3DDevice9 *device = DX8Wrapper::_Get_D3D_Device8(); + if (device == nullptr) { + Stop_Movie_Capture(); + return false; + } + + IDirect3DSurface9 *back_buffer = nullptr; + if (FAILED(device->GetBackBuffer(0, 0, D3DBACKBUFFER_TYPE_MONO, &back_buffer)) || + back_buffer == nullptr) { + Stop_Movie_Capture(); + return false; + } + + D3DSURFACE_DESC desc; + const HRESULT desc_result = back_buffer->GetDesc(&desc); + if (FAILED(desc_result) || + desc.Width != static_cast(Movie->GetWidth()) || + desc.Height != static_cast(Movie->GetHeight())) { + back_buffer->Release(); + Stop_Movie_Capture(); + return false; + } + + IDirect3DSurface9 *resolved_buffer = nullptr; + IDirect3DSurface9 *capture_source = back_buffer; + if (desc.MultiSampleType != D3DMULTISAMPLE_NONE) { + HRESULT result = device->CreateRenderTarget(desc.Width, + desc.Height, + desc.Format, + D3DMULTISAMPLE_NONE, + 0, + FALSE, + &resolved_buffer, + nullptr); + if (SUCCEEDED(result)) { + result = device->StretchRect(back_buffer, nullptr, resolved_buffer, nullptr, D3DTEXF_NONE); + } + if (FAILED(result) || resolved_buffer == nullptr) { + if (resolved_buffer != nullptr) { + resolved_buffer->Release(); + } + back_buffer->Release(); + Stop_Movie_Capture(); + return false; + } + capture_source = resolved_buffer; + } + + IDirect3DSurface9 *staging_buffer = nullptr; + HRESULT copy_result = device->CreateOffscreenPlainSurface(desc.Width, + desc.Height, + desc.Format, + D3DPOOL_SYSTEMMEM, + &staging_buffer, + nullptr); + if (SUCCEEDED(copy_result)) { + copy_result = device->GetRenderTargetData(capture_source, staging_buffer); + } + + if (resolved_buffer != nullptr) { + resolved_buffer->Release(); + } + back_buffer->Release(); + if (FAILED(copy_result) || staging_buffer == nullptr) { + if (staging_buffer != nullptr) { + staging_buffer->Release(); + } + Stop_Movie_Capture(); + return false; + } - Movie->Grab(image); + D3DLOCKED_RECT locked; + const HRESULT lock_result = staging_buffer->LockRect(&locked, nullptr, D3DLOCK_READONLY); + if (FAILED(lock_result)) { + staging_buffer->Release(); + Stop_Movie_Capture(); + return false; + } + + unsigned char *image = reinterpret_cast(Movie->GetBuffer()); + const unsigned int destination_stride = Movie->GetBufferStride(); + const unsigned int destination_bytes = desc.Width * 3; + bool format_supported = image != nullptr && destination_stride >= destination_bytes; + for (unsigned int y = 0; format_supported && y < desc.Height; ++y) { + unsigned char *destination = image + (desc.Height - y - 1) * destination_stride; + const unsigned char *source = + reinterpret_cast(locked.pBits) + y * locked.Pitch; + + for (unsigned int x = 0; x < desc.Width; ++x) { + unsigned int red = 0; + unsigned int green = 0; + unsigned int blue = 0; + switch (desc.Format) { + case D3DFMT_A8R8G8B8: + case D3DFMT_X8R8G8B8: + blue = source[4 * x]; + green = source[4 * x + 1]; + red = source[4 * x + 2]; + break; + case D3DFMT_A8B8G8R8: + case D3DFMT_X8B8G8R8: + red = source[4 * x]; + green = source[4 * x + 1]; + blue = source[4 * x + 2]; + break; + case D3DFMT_R5G6B5: { + const unsigned int pixel = reinterpret_cast(source)[x]; + blue = ((pixel & 0x1fU) * 255U + 15U) / 31U; + green = (((pixel >> 5) & 0x3fU) * 255U + 31U) / 63U; + red = (((pixel >> 11) & 0x1fU) * 255U + 15U) / 31U; + break; + } + case D3DFMT_A1R5G5B5: + case D3DFMT_X1R5G5B5: { + const unsigned int pixel = reinterpret_cast(source)[x]; + blue = ((pixel & 0x1fU) * 255U + 15U) / 31U; + green = (((pixel >> 5) & 0x1fU) * 255U + 15U) / 31U; + red = (((pixel >> 10) & 0x1fU) * 255U + 15U) / 31U; + break; + } + case D3DFMT_A2R10G10B10: { + const unsigned int pixel = reinterpret_cast(source)[x]; + blue = ((pixel & 0x3ffU) * 255U + 511U) / 1023U; + green = (((pixel >> 10) & 0x3ffU) * 255U + 511U) / 1023U; + red = (((pixel >> 20) & 0x3ffU) * 255U + 511U) / 1023U; + break; + } + default: + format_supported = false; + break; + } + + if (!format_supported) { + break; + } + destination[3 * x] = static_cast(blue); + destination[3 * x + 1] = static_cast(green); + destination[3 * x + 2] = static_cast(red); + } + + if (format_supported && destination_stride > destination_bytes) { + memset(destination + destination_bytes, 0, destination_stride - destination_bytes); + } + } + + const HRESULT unlock_result = staging_buffer->UnlockRect(); + staging_buffer->Release(); + if (!format_supported || FAILED(unlock_result) || !Movie->Grab(image)) { + Stop_Movie_Capture(); + return false; + } + + return true; +#else + return false; #endif } @@ -1615,7 +1898,7 @@ void WW3D::Update_Movie_Capture( void ) float WW3D::Get_Movie_Capture_Frame_Rate( void ) { #ifdef _WIN32 - if (IsCapturing) { + if (Is_Movie_Ready()) { return Movie->GetFrameRate(); } #endif diff --git a/Code/ww3d2/ww3d.h b/Code/ww3d2/ww3d.h index b5e10578e..d4802c4ee 100644 --- a/Code/ww3d2/ww3d.h +++ b/Code/ww3d2/ww3d.h @@ -173,12 +173,16 @@ class WW3D ** These functions allow you to create screenshots and movies. */ static void Make_Screen_Shot( const char * filename = "ScreenShot"); + static int Make_Back_Buffer_Screen_Shot( const char * filename = "ScreenShot"); static void Start_Movie_Capture( const char * filename_base = "Movie", float frame_rate = 15); + static bool Try_Start_Movie_Capture( const char * filename_base = "Movie", float frame_rate = 15); static void Stop_Movie_Capture( void); static void Toggle_Movie_Capture( const char * filename_base = "Movie", float frame_rate = 15); static void Start_Single_Frame_Movie_Capture(const char *filename_base = "Frames"); static void Capture_Next_Movie_Frame(); static void Update_Movie_Capture( void); + static bool Try_Update_Movie_Capture( void); + static bool Try_Update_Movie_Capture_From_Back_Buffer( void); static float Get_Movie_Capture_Frame_Rate( void); static void Pause_Movie(bool mode); static bool Is_Movie_Paused(); diff --git a/Code/wwlib/CMakeLists.txt b/Code/wwlib/CMakeLists.txt index 1e8bc455f..6fcc804ca 100644 --- a/Code/wwlib/CMakeLists.txt +++ b/Code/wwlib/CMakeLists.txt @@ -178,3 +178,20 @@ if(WIN32) endif() target_sources(wwlib PRIVATE ${WWLIB_SRC}) + +if(BUILD_TESTING) + add_executable(wwlib_mempool_tests + tests/MempoolTests.cpp + ) + + target_link_libraries(wwlib_mempool_tests PRIVATE + wwlib + wwcommon + ) + + target_include_directories(wwlib_mempool_tests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ) + + add_test(NAME wwlib_mempool_tests COMMAND wwlib_mempool_tests) +endif() diff --git a/Code/wwlib/Except.cpp b/Code/wwlib/Except.cpp index f31b1d26c..f7b5d4f77 100644 --- a/Code/wwlib/Except.cpp +++ b/Code/wwlib/Except.cpp @@ -185,6 +185,8 @@ static SymGetModuleBaseType _SymGetModuleBase = nullptr; * HISTORY: * * 8/22/00 11:42AM ST : Created * *=============================================================================================*/ +// MSVC 2015+ ships _purecall in vcruntime, so avoid a duplicate definition. +#if !defined(_MSC_VER) || _MSC_VER < 1900 int _purecall(void) { int return_code = 0; @@ -199,6 +201,7 @@ int _purecall(void) return(return_code); } +#endif // _MSC_VER < 1900 @@ -1329,5 +1332,3 @@ bool Is_Trying_To_Exit(void) #endif //_MSC_VER - - diff --git a/Code/wwlib/chunkio.cpp b/Code/wwlib/chunkio.cpp index a691a008a..9306ad542 100644 --- a/Code/wwlib/chunkio.cpp +++ b/Code/wwlib/chunkio.cpp @@ -87,7 +87,8 @@ ChunkSaveClass::ChunkSaveClass(FileClass * file) : File(file), StackIndex(0), InMicroChunk(false), - MicroChunkPosition(0) + MicroChunkPosition(0), + WriteError(false) { memset(PositionStack,0,sizeof(PositionStack)); memset(HeaderStack,0,sizeof(HeaderStack)); @@ -113,9 +114,9 @@ bool ChunkSaveClass::Begin_Chunk(uint32 id) ChunkHeader chunkh; int filepos; - // If we have a parent chunk, set its 'Contains_Chunks' flag - if (StackIndex > 0) { - HeaderStack[StackIndex-1].Set_Sub_Chunk_Flag(true); + if ((File == nullptr) || InMicroChunk || (StackIndex >= MAX_STACK_DEPTH)) { + WriteError = true; + return false; } // Save the current file position and chunk header @@ -123,6 +124,10 @@ bool ChunkSaveClass::Begin_Chunk(uint32 id) chunkh.Set_Type(id); chunkh.Set_Size(0); filepos = File->Seek(0); + if (filepos < 0) { + WriteError = true; + return false; + } PositionStack[StackIndex] = filepos; HeaderStack[StackIndex] = chunkh; @@ -130,8 +135,16 @@ bool ChunkSaveClass::Begin_Chunk(uint32 id) // write a temporary chunk header (size = 0) if (File->Write(&chunkh,sizeof(chunkh)) != sizeof(chunkh)) { + StackIndex--; + WriteError = true; return false; } + + // Only mark the parent after the child header exists. A failed Begin_Chunk + // must leave both the stack depth and the parent header unchanged. + if (StackIndex > 1) { + HeaderStack[StackIndex-2].Set_Sub_Chunk_Flag(true); + } return true; } @@ -150,8 +163,10 @@ bool ChunkSaveClass::Begin_Chunk(uint32 id) *=============================================================================================*/ bool ChunkSaveClass::End_Chunk(void) { - // If the user didn't close his micro chunks bad things are gonna happen - assert(!InMicroChunk); + if ((File == nullptr) || InMicroChunk || (StackIndex <= 0)) { + WriteError = true; + return false; + } // Save the current position int curpos = File->Seek(0); @@ -162,20 +177,22 @@ bool ChunkSaveClass::End_Chunk(void) ChunkHeader chunkh = HeaderStack[StackIndex]; // write the completed header - File->Seek(chunkpos,SEEK_SET); - if (File->Write(&chunkh,sizeof(chunkh)) != sizeof(chunkh)) { - return false; - } + const bool found_header = (curpos >= 0) && (File->Seek(chunkpos,SEEK_SET) == chunkpos); + const bool wrote_header = found_header && + (File->Write(&chunkh,sizeof(chunkh)) == sizeof(chunkh)); // Add the total bytes written to any encompasing chunk - if (StackIndex != 0) { + if (wrote_header && StackIndex != 0) { HeaderStack[StackIndex-1].Add_Size(chunkh.Get_Size() + sizeof(chunkh)); } // Go back to the end of the file - File->Seek(curpos,SEEK_SET); + const bool restored_position = (curpos >= 0) && (File->Seek(curpos,SEEK_SET) == curpos); + if (!wrote_header || !restored_position) { + WriteError = true; + } - return true; + return wrote_header && restored_position && !WriteError; } @@ -199,14 +216,20 @@ bool ChunkSaveClass::End_Chunk(void) *=============================================================================================*/ bool ChunkSaveClass::Begin_Micro_Chunk(uint32 id) { - assert(id < 256); - assert(!InMicroChunk); + if ((File == nullptr) || (id >= 256) || InMicroChunk || (StackIndex <= 0)) { + WriteError = true; + return false; + } // Save the current file position and chunk header // for the call to End_Micro_Chunk. MCHeader.Set_Type(uint8(id)); MCHeader.Set_Size(0); MicroChunkPosition = File->Seek(0); + if (MicroChunkPosition < 0) { + WriteError = true; + return false; + } // Write a temporary chunk header // NOTE: I'm calling the ChunkSaveClass::Write method so that the bytes for @@ -235,21 +258,38 @@ bool ChunkSaveClass::Begin_Micro_Chunk(uint32 id) *=============================================================================================*/ bool ChunkSaveClass::End_Micro_Chunk(void) { - assert(InMicroChunk); + if ((File == nullptr) || !InMicroChunk) { + WriteError = true; + return false; + } // Save the current position int curpos = File->Seek(0); // Seek back and write the micro chunk header - File->Seek(MicroChunkPosition,SEEK_SET); - if (File->Write(&MCHeader,sizeof(MCHeader)) != sizeof(MCHeader)) { - return false; - } + const bool found_header = (curpos >= 0) && + (File->Seek(MicroChunkPosition,SEEK_SET) == MicroChunkPosition); + const bool wrote_header = found_header && + (File->Write(&MCHeader,sizeof(MCHeader)) == sizeof(MCHeader)); // Go back to the end of the file - File->Seek(curpos,SEEK_SET); + const bool restored_position = (curpos >= 0) && (File->Seek(curpos,SEEK_SET) == curpos); InMicroChunk = false; - return true; + if (!wrote_header || !restored_position) { + WriteError = true; + } + return wrote_header && restored_position && !WriteError; +} + +bool ChunkSaveClass::Write_Micro_Chunk(uint32 id, const void *buf, size_t nbytes) +{ + if (!Begin_Micro_Chunk(id)) { + return false; + } + + const bool wrote_data = Write(buf, nbytes) == nbytes; + const bool ended = End_Micro_Chunk(); + return wrote_data && ended && !WriteError; } /*********************************************************************************************** @@ -266,30 +306,33 @@ bool ChunkSaveClass::End_Micro_Chunk(void) *=============================================================================================*/ uint32 ChunkSaveClass::Write(const void * buf, size_t nbytes) { - // If this assert hits, you mixed data and chunks within the same chunk NO NO! - assert(HeaderStack[StackIndex-1].Get_Sub_Chunk_Flag() == 0); - - // If this assert hits, you didnt open any chunks yet - assert(StackIndex > 0); - - const size_t clamped_to_u32 = std::min(nbytes, static_cast(std::numeric_limits::max())); - assert(clamped_to_u32 == nbytes); - const uint32 nbytes32 = static_cast(clamped_to_u32); + if ((File == nullptr) || (StackIndex <= 0) || + (HeaderStack[StackIndex-1].Get_Sub_Chunk_Flag() != 0)) { + WriteError = true; + return 0; + } - const size_t clamped_to_int = std::min(clamped_to_u32, static_cast(std::numeric_limits::max())); - assert(clamped_to_int == clamped_to_u32); - const int nbytes_int = static_cast(clamped_to_int); + if ((nbytes > static_cast(std::numeric_limits::max())) || + (nbytes > static_cast(std::numeric_limits::max())) || + (InMicroChunk && + (nbytes > static_cast(std::numeric_limits::max()) - MCHeader.Get_Size()))) { + WriteError = true; + return 0; + } + const uint32 nbytes32 = static_cast(nbytes); + const int nbytes_int = static_cast(nbytes); // write the bytes into the file - if (File->Write(buf, nbytes_int) != nbytes_int) return 0; + if (File->Write(buf, nbytes_int) != nbytes_int) { + WriteError = true; + return 0; + } // track them in the wrapping chunk HeaderStack[StackIndex - 1].Add_Size(nbytes32); // track them if you are using a micro-chunk too. if (InMicroChunk) { - assert(nbytes32 <= std::numeric_limits::max()); // micro chunks can only be 255 bytes - assert(static_cast(MCHeader.Get_Size()) <= static_cast(std::numeric_limits::max()) - nbytes32); MCHeader.Add_Size(static_cast(nbytes32)); } @@ -864,4 +907,3 @@ uint32 ChunkLoadClass::Read(IOQuaternionStruct * q) assert(q != nullptr); return Read(q,sizeof(*q)); } - diff --git a/Code/wwlib/chunkio.h b/Code/wwlib/chunkio.h index 3b0d4492a..c95a0a384 100644 --- a/Code/wwlib/chunkio.h +++ b/Code/wwlib/chunkio.h @@ -161,6 +161,7 @@ class ChunkSaveClass // Micro chunk methods bool Begin_Micro_Chunk(uint32 id); bool End_Micro_Chunk(); + bool Write_Micro_Chunk(uint32 id, const void *buf, size_t nbytes); // Write data into the file uint32 Write(const void *buf, size_t nbytes); @@ -168,6 +169,7 @@ class ChunkSaveClass uint32 Write(const IOVector3Struct & v); uint32 Write(const IOVector4Struct & v); uint32 Write(const IOQuaternionStruct & q); + bool Has_Write_Error() const { return WriteError; } private: @@ -184,6 +186,7 @@ class ChunkSaveClass bool InMicroChunk; int MicroChunkPosition; MicroChunkHeader MCHeader; + bool WriteError; }; @@ -311,38 +314,26 @@ class ChunkLoadClass */ #define WRITE_MICRO_CHUNK(csave,id,var) { \ static_assert(!std::is_pointer_v); \ - csave.Begin_Micro_Chunk(id); \ - csave.Write(&var,sizeof(var)); \ - csave.End_Micro_Chunk(); } + csave.Write_Micro_Chunk(id,&var,sizeof(var)); } #define WRITE_MICRO_CHUNK_PTR(csave,id,var) { \ static_assert(std::is_pointer_v); \ - csave.Begin_Micro_Chunk(id); \ const uint32 _id_##var = SaveLoadSystemClass::Serialize_Pointer(var); \ - csave.Write(&_id_##var,sizeof(uint32)); \ - csave.End_Micro_Chunk(); } + csave.Write_Micro_Chunk(id,&_id_##var,sizeof(uint32)); } #define WRITE_SAFE_MICRO_CHUNK(csave,id,var,type) { \ - csave.Begin_Micro_Chunk(id); \ static_assert(!std::is_pointer_v); \ type data = (type)var; \ - csave.Write(&data,sizeof(data)); \ - csave.End_Micro_Chunk(); } + csave.Write_Micro_Chunk(id,&data,sizeof(data)); } #define WRITE_MICRO_CHUNK_STRING(csave,id,var) { \ - csave.Begin_Micro_Chunk(id); \ - csave.Write(var, strlen(var) + 1); \ - csave.End_Micro_Chunk(); } + csave.Write_Micro_Chunk(id,var, strlen(var) + 1); } #define WRITE_MICRO_CHUNK_WWSTRING(csave,id,var) { \ - csave.Begin_Micro_Chunk(id); \ - csave.Write((const char *)var, static_cast(var.Get_Length ()) + 1); \ - csave.End_Micro_Chunk(); } + csave.Write_Micro_Chunk(id,(const char *)var, static_cast(var.Get_Length ()) + 1); } #define WRITE_MICRO_CHUNK_WIDESTRING(csave,id,var) { \ - csave.Begin_Micro_Chunk(id); \ - csave.Write((const unichar_t *)var, (static_cast(var.Get_Length ()) + 1) * 2); \ - csave.End_Micro_Chunk(); } + csave.Write_Micro_Chunk(id,(const unichar_t *)var, (static_cast(var.Get_Length ()) + 1) * 2); } /* diff --git a/Code/wwlib/mempool.h b/Code/wwlib/mempool.h index 95ff489c9..a3a0ccf1a 100644 --- a/Code/wwlib/mempool.h +++ b/Code/wwlib/mempool.h @@ -91,7 +91,7 @@ class ObjectPoolClass protected: T * FreeListHead; - uint32 * BlockListHead; + void * BlockListHead; int FreeObjectCount; int TotalObjectCount; FastCriticalSectionClass ObjectPoolCS; @@ -211,8 +211,10 @@ ObjectPoolClass::~ObjectPoolClass(void) // delete all of the blocks we allocated int block_count = 0; while (BlockListHead != nullptr) { - uint32 * next_block = *(uint32 **)BlockListHead; - ::operator delete(BlockListHead); + void * next_block = *(void **)BlockListHead; + constexpr size_t block_alignment = + alignof(T) > alignof(void *) ? alignof(T) : alignof(void *); + ::operator delete(BlockListHead, std::align_val_t(block_alignment)); BlockListHead = next_block; block_count++; } @@ -282,18 +284,29 @@ void ObjectPoolClass::Free_Object(T * obj) template T * ObjectPoolClass::Allocate_Object_Memory(void) { + static_assert(BLOCK_SIZE > 0, "Object pools require a positive block size"); + static_assert(sizeof(T) >= sizeof(T *), + "Object pool slots must be large enough to store a free-list pointer"); + FastCriticalSectionClass::LockClass lock(ObjectPoolCS); if ( FreeListHead == 0 ) { // No free objects, allocate another block - uint32 * tmp_block_head = BlockListHead; - BlockListHead = (uint32*)::operator new( sizeof(T) * BLOCK_SIZE + sizeof(uint32 *)); + void * tmp_block_head = BlockListHead; + constexpr size_t block_header_size = + (sizeof(void *) + alignof(T) - 1) & ~(alignof(T) - 1); + constexpr size_t block_alignment = + alignof(T) > alignof(void *) ? alignof(T) : alignof(void *); + BlockListHead = ::operator new( + sizeof(T) * BLOCK_SIZE + block_header_size, + std::align_val_t(block_alignment)); // Link this block into the block list *(void **)BlockListHead = tmp_block_head; // Link the objects in the block into the free object list - FreeListHead = (T*)((uint32**)BlockListHead + 1); + FreeListHead = reinterpret_cast( + reinterpret_cast(BlockListHead) + block_header_size); for ( int i = 0; i < BLOCK_SIZE; i++ ) { *(T**)(&(FreeListHead[i])) = &(FreeListHead[i+1]); // link up the elements } diff --git a/Code/wwlib/tests/MempoolTests.cpp b/Code/wwlib/tests/MempoolTests.cpp new file mode 100644 index 000000000..6cecb2dd9 --- /dev/null +++ b/Code/wwlib/tests/MempoolTests.cpp @@ -0,0 +1,74 @@ +#include "mempool.h" + +#include +#include +#include + +namespace { + +struct alignas(32) PooledValue +{ + std::uintptr_t first = 0; + std::uintptr_t second = 0; +}; + +struct alignas(32) AutoPooledValue : public AutoPoolClass +{ + std::uintptr_t value = 0; +}; + +} // namespace + +int main() +{ + { + ObjectPoolClass pool; + std::vector values; + + for (std::uintptr_t index = 0; index < 10; ++index) { + PooledValue *value = pool.Allocate_Object(); + if ((reinterpret_cast(value) % alignof(PooledValue)) != 0) { + std::cerr << "Object pool returned a misaligned value.\n"; + return 1; + } + value->first = index; + value->second = index + 100; + values.push_back(value); + } + + for (std::uintptr_t index = 0; index < values.size(); ++index) { + if (values[index]->first != index || values[index]->second != index + 100) { + std::cerr << "Object pool values overlapped or were corrupted.\n"; + return 1; + } + } + + for (PooledValue *value : values) { + pool.Free_Object(value); + } + } + + std::vector automatic_values; + for (std::uintptr_t index = 0; index < 10; ++index) { + AutoPooledValue *value = new AutoPooledValue; + if ((reinterpret_cast(value) % alignof(AutoPooledValue)) != 0) { + std::cerr << "Automatic object pool returned a misaligned value.\n"; + return 1; + } + value->value = index; + automatic_values.push_back(value); + } + + for (std::uintptr_t index = 0; index < automatic_values.size(); ++index) { + if (automatic_values[index]->value != index) { + std::cerr << "Automatic object pool values were corrupted.\n"; + return 1; + } + } + + for (AutoPooledValue *value : automatic_values) { + delete value; + } + + return 0; +} diff --git a/Code/wwsaveload/definition.cpp b/Code/wwsaveload/definition.cpp index 085042346..b05eda431 100644 --- a/Code/wwsaveload/definition.cpp +++ b/Code/wwsaveload/definition.cpp @@ -61,13 +61,14 @@ enum bool DefinitionClass::Save (ChunkSaveClass &csave) { - bool retval = true; + bool retval = false; - csave.Begin_Chunk (CHUNKID_VARIABLES); - retval &= Save_Variables (csave); - csave.End_Chunk (); + if (csave.Begin_Chunk (CHUNKID_VARIABLES)) { + retval = Save_Variables (csave); + retval = csave.End_Chunk () && retval; + } - return retval; + return retval && !csave.Has_Write_Error (); } @@ -106,9 +107,10 @@ DefinitionClass::Save_Variables (ChunkSaveClass &csave) { bool retval = true; - WRITE_MICRO_CHUNK (csave, VARID_INSTANCEID, m_ID); - WRITE_MICRO_CHUNK_WWSTRING (csave, VARID_NAME, m_Name); - return retval; + retval &= csave.Write_Micro_Chunk (VARID_INSTANCEID, &m_ID, sizeof (m_ID)); + retval &= csave.Write_Micro_Chunk (VARID_NAME, (const char *)m_Name, + static_cast(m_Name.Get_Length ()) + 1); + return retval && !csave.Has_Write_Error (); } @@ -160,4 +162,3 @@ DefinitionClass::Set_ID (uint32 id) return ; } - diff --git a/cmake/dx9.cmake b/cmake/dx9.cmake index f7b6ca5db..aaa4d2df1 100644 --- a/cmake/dx9.cmake +++ b/cmake/dx9.cmake @@ -7,6 +7,11 @@ if(WIN32) ) FetchContent_MakeAvailable(dx9) + if(MSVC) + # The prebuilt legacy DxErr.lib still imports _vsnprintf, which + # moved to this compatibility library with the Universal CRT. + target_link_libraries(d3d9lib INTERFACE legacy_stdio_definitions) + endif() else() add_library(d3d9lib INTERFACE) target_link_libraries(d3d9lib INTERFACE d3d9 d3dx9) @@ -23,4 +28,3 @@ else() target_include_directories(d3d9lib INTERFACE "${PROJECT_SOURCE_DIR}/Code/dxvk_wrapper") target_include_directories(d3d9lib INTERFACE "${DXVK_INCLUDE_PATH}/dxvk") endif() -