diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2adef0b..fa5d661 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,198 +1,231 @@ -name: Build Clean Models QT +name: Build on: push: - branches: - - main - paths-ignore: - - '**.md' + branches: [main] + tags: ["v*"] pull_request: - branches: - - main - paths-ignore: - - '**.md' + branches: [main] + +permissions: + contents: read jobs: build: - runs-on: ${{ matrix.os }} strategy: matrix: include: - - os: ubuntu-latest - target: windows-static - zip_name: cleanmodels-qt.windows-static.amd64 - executable: cleanmodels-qt.exe - static_build: true - - os: ubuntu-latest - target: windows-shared - zip_name: cleanmodels-qt.windows-shared.amd64 - executable: cleanmodels-qt.exe - - os: ubuntu-16.04 - target: linux-static - zip_name: cleanmodels-qt.linux-static.amd64 - executable: cleanmodels-qt - static_build: true - - os: ubuntu-latest - target: linux-shared - zip_name: cleanmodels-qt.linux-shared.amd64 - executable: cleanmodels-qt - - os: macos-latest - target: macosx-static - zip_name: cleanmodels-qt.macosx-static.amd64 - executable: cleanmodels-qt - static_build: true + - os: ubuntu-22.04 + name: linux-amd64 + cmake_extra: "" + # GitHub-hosted ARM Linux runner (public-preview as of Jan 2025, + # free for public repos). linuxdeploy + linuxdeploy-plugin-qt + # both ship aarch64 AppImages on their continuous channel; the + # Linux deploy step picks the right one via `uname -m`. + - os: ubuntu-22.04-arm + name: linux-arm64 + cmake_extra: "" + # Single Apple Silicon runner producing a universal2 binary + # that runs natively on both x86_64 and arm64 Macs. Qt 6.2+ + # ships a universal macOS desktop tarball, macdeployqt copes + # with fat .app bundles, and we lipo the bundled CLI from + # cleanmodels' two darwin assets in the download step. Avoids + # the macos-13 Intel runner which is queue-starved on free + # public-repo minutes and slated for eventual deprecation. - os: macos-latest - target: macosx-shared - zip_name: cleanmodels-qt.macosx-shared.amd64 - executable: cleanmodels-qt - - name: Building ${{ matrix.zip_name }} + name: macos + cmake_extra: "-DCMAKE_OSX_ARCHITECTURES='x86_64;arm64'" + - os: windows-latest + name: windows-amd64 + cmake_extra: "" + runs-on: ${{ matrix.os }} + name: Build ${{ matrix.name }} steps: - - uses: actions/checkout@v2 - - - name: Get QT Build Deps - if: matrix.target == 'linux-static' - run: | - sudo apt-get update - sudo apt-get install libxkbcommon-dev libxkbcommon-x11-dev '^libxcb.*-dev' libx11-xcb-dev libglu1-mesa-dev libxrender-dev libxi-dev - - - name: Checkout QT - if: (matrix.target == 'linux-static' || matrix.target == 'macosx-static') - uses: actions/checkout@v2 - with: - repository: qt/qt5 - path: qt5 - ref: 5.15.2 - - - name: Get qtbase - if: (matrix.target == 'linux-static' || matrix.target == 'macosx-static') - working-directory: ./qt5 - run: git submodule update --init --depth 1 -- qtbase - - - name: Build QT Static - if: (matrix.target == 'linux-static' || matrix.target == 'macosx-static') - run: | - mkdir qt_build - cd qt_build - ../qt5/configure -v -prefix "${{ github.workspace }}/Qt" -static -release -optimize-size -ltcg -make libs -no-pch -no-zlib -qt-harfbuzz -qt-pcre -qt-libpng -no-libjpeg -no-libmd4c -nomake tools -nomake examples -nomake tests -no-feature-accessibility -no-feature-accessibility-atspi-bridge -no-feature-android-style-assets -no-feature-animation -no-feature-appstore-compliant -no-feature-avx2 -no-feature-bearermanagement -no-feature-calendarwidget -no-feature-clock-gettime -no-feature-colordialog -no-feature-commandlinkbutton -no-feature-concatenatetablesproxymodel -no-feature-cross_compile -no-feature-cups -no-feature-datetimeedit -no-feature-datetimeparser -no-feature-desktopservices -no-feature-dial -no-feature-direct2d -no-feature-direct2d1_1 -no-feature-directfb -no-feature-drm_atomic -no-feature-dtls -no-feature-easingcurve -no-feature-effects -no-feature-fontcombobox -no-feature-fontdialog -no-feature-ftp -no-feature-future -no-feature-gestures -no-feature-gif -no-feature-gssapi -no-feature-gtk3 -no-feature-hijricalendar -no-feature-ico -no-feature-identityproxymodel -no-feature-im -no-feature-imageformatplugin -no-feature-image_heuristic_mask -no-feature-image_text -no-feature-islamiccivilcalendar -no-feature-itemmodeltester -no-feature-jalalicalendar -no-feature-jpeg -no-feature-keysequenceedit -no-feature-kms -no-feature-lcdnumber -no-feature-linkat -no-feature-lttng -no-feature-mdiarea -no-feature-mimetype-database -no-feature-movie -no-feature-network -no-feature-ocsp -no-feature-opengl -no-feature-opengles2 -no-feature-opengles3 -no-feature-opengles31 -no-feature-opengles32 -no-feature-openssl -no-feature-pdf -no-feature-picture -no-feature-printdialog -no-feature-printer -no-feature-printpreviewdialog -no-feature-printpreviewwidget -no-feature-raster-64bit -no-feature-release_tools -no-feature-relocatable -no-feature-sha3-fast -no-feature-sql -no-feature-sqlmodel -no-feature-sql-mysql -no-feature-sql-odbc -no-feature-sql-psql -no-feature-sql-sqlite -no-feature-sql-sqlite2 -no-feature-sql-tds -no-feature-statemachine -no-feature-style-android -no-feature-style-mac -no-feature-style-windowsvista -no-feature-syntaxhighlighter -no-feature-systemtrayicon -no-feature-system-textmarkdownreader -no-feature-tabletevent -no-feature-testlib -no-feature-textmarkdownreader -no-feature-textmarkdownwriter -no-feature-textodfwriter -no-feature-timezone -no-feature-treewidget -no-feature-tuiotouch -no-feature-valgrind -no-feature-vnc -no-feature-wizard -no-feature-xmlstreamwriter -no-feature-zstd -opensource -confirm-license -skip qt3d -skip qtactiveqt -skip qtandroidextras -skip qtcharts -skip qtdatavis3d -skip qtdeclarative -skip qtgamepad -skip qtlocation -skip qtlottie -skip qtmultimedia -skip qtpim -skip qtpurchasing -skip qtqa -skip qtquickcontrols -skip qtquickcontrols2 -skip qtrepotools -skip qtscript -skip qtscxml -skip qtsensors -skip qtserialport -skip qtspeech -skip qtsvg -skip qttools -skip qttranslations -skip qtwebchannel -skip qtwebengine -skip qtxmlpatterns -skip serialbus -skip wayland - make module-qtbase -j4 - sudo make install -j4 - - - name: MXE Repository - if: matrix.target == 'windows-static' || matrix.target == 'windows-shared' - run: | - sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 86B72ED9 - sudo add-apt-repository 'deb [arch=amd64] https://mirror.mxe.cc/repos/apt bionic main' - - - name: Install Windows Static Build Dependencies - if: matrix.target == 'windows-static' - run: | - sudo apt-get install mxe-x86-64-w64-mingw32.static-qtbase - echo "/usr/lib/mxe/usr/bin" >> $GITHUB_PATH - - - name: Install Windows Shared Build Dependencies - if: matrix.target == 'windows-shared' - run: | - sudo apt-get install mxe-x86-64-w64-mingw32.shared-qtbase - echo "/usr/lib/mxe/usr/bin" >> $GITHUB_PATH - - - name: Install Qt for MacOS Shared Builds - uses: jurplel/install-qt-action@v2 - if: matrix.target == 'macosx-shared' - - - name: Install Linux Shared Build Dependencies - if: matrix.target == 'linux-shared' - run: | - sudo apt-get update - sudo apt-get install qtbase5-dev - - - name: Build Windows Static Binary - if: matrix.target == 'windows-static' - run: | - x86_64-w64-mingw32.static-qmake-qt5 - make -j4 - - - name: Build Windows Shared Binary - if: matrix.target == 'windows-shared' - run: | - x86_64-w64-mingw32.shared-qmake-qt5 - make -j4 - - - name: Get latest CMake and ninja - if: matrix.target != 'windows-static' && matrix.target != 'windows-shared' - uses: lukka/get-cmake@latest - - - name: Build Linux and MacOS Binaries - env: - STATIC_BUILD: ${{ matrix.static_build }} - QT_STATIC_PATH: ${{ github.workspace }}/Qt - if: matrix.target != 'windows-static' && matrix.target != 'windows-shared' - run: | - mkdir build - cd build - cmake .. - make -j4 - mkdir ../release - strip cleanmodels-qt - mv cleanmodels-qt ../release - - - name: Compress - working-directory: ./release - run: | - mkdir ../zips - zip ${{ github.workspace }}/zips/${{ matrix.zip_name }}.zip ${{ matrix.executable }} - - - name: Upload Binaries - uses: actions/upload-artifact@v2 - if: github.event_name == 'push' - with: - path: ${{ github.workspace }}/zips - name: ${{ matrix.zip_name }}.zip + - uses: actions/checkout@v6 + + - name: Install Qt6 + uses: jurplel/install-qt-action@v4 + with: + version: "6.7.*" + cache: true + + - name: Install dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libgl1-mesa-dev libglu1-mesa-dev libxcb-cursor0 libfuse2 + + - name: Configure + run: cmake -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr ${{ matrix.cmake_extra }} + + - name: Build + run: cmake --build build --config Release --parallel + + - name: Download cleanmodels CLI + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Bundle the matching cleanmodels release alongside the GUI so a + # single download includes both. The GUI looks for `cleanmodels` + # on PATH or next to its own binary, so the layout below lands + # it in the second of those slots on every platform. + # + # `releases/latest/download/` resolves only to non-pre-release + # tags. That's the simple long-term URL once v4.0.0 stable is + # cut. Until then we fall back to the most recent release of + # any kind via `gh` so PR/CI builds during the RC window still + # bundle something coherent. + mkdir -p cli-bundle + + fetch_asset() { + local asset="$1" + local stable="https://github.com/plenarius/cleanmodels/releases/latest/download/${asset}" + if curl -fsSL --output "cli-bundle/${asset}" "${stable}"; then + echo "Pulled ${asset} from cleanmodels latest stable release" + else + echo "No stable cleanmodels release for ${asset}; falling back to most recent (incl. pre-releases)" + local tag + tag=$(gh release list -R plenarius/cleanmodels --limit 1 --json tagName -q '.[0].tagName') + echo "Most recent tag: ${tag}" + gh release download "$tag" -R plenarius/cleanmodels -p "$asset" --dir cli-bundle --clobber + fi + } + + case "${{ matrix.name }}" in + macos) + # Build a universal2 CLI from the two darwin assets so the + # bundled binary runs natively on both Intel and Apple + # Silicon, matching the .app's CMAKE_OSX_ARCHITECTURES. + fetch_asset cleanmodels-darwin-amd64.zip + fetch_asset cleanmodels-darwin-arm64.zip + cd cli-bundle + unzip -o cleanmodels-darwin-amd64.zip -d amd64 + unzip -o cleanmodels-darwin-arm64.zip -d arm64 + lipo -create amd64/cleanmodels-darwin-amd64 arm64/cleanmodels-darwin-arm64 \ + -output cleanmodels + chmod +x cleanmodels + lipo -info cleanmodels + rm -rf amd64 arm64 cleanmodels-darwin-*.zip + ;; + windows-amd64) + fetch_asset cleanmodels-windows-amd64.zip + cd cli-bundle + unzip -o cleanmodels-windows-amd64.zip + mv cleanmodels-windows-amd64.exe cleanmodels.exe + ;; + linux-*) + fetch_asset "cleanmodels-${{ matrix.name }}.zip" + cd cli-bundle + unzip -o "cleanmodels-${{ matrix.name }}.zip" + mv "cleanmodels-${{ matrix.name }}" cleanmodels + chmod +x cleanmodels + ;; + *) + echo "Unhandled matrix name: ${{ matrix.name }}" >&2 + exit 1 + ;; + esac + + # The case arms each `cd cli-bundle`, so list with a relative + # path that works whether or not we're already inside it. + ls -la "${GITHUB_WORKSPACE}/cli-bundle" + + - name: Deploy AppImage (Linux) + if: runner.os == 'Linux' + run: | + # Install to AppDir + DESTDIR=AppDir cmake --install build + + # Bundle the cleanmodels CLI alongside the GUI inside the + # AppImage. linuxdeploy preserves anything already in AppDir, + # so dropping the binary in usr/bin/ before it runs is enough. + install -Dm755 cli-bundle/cleanmodels AppDir/usr/bin/cleanmodels + + # linuxdeploy and its Qt plugin ship per-arch AppImages on the + # continuous channel. `uname -m` resolves to x86_64 on amd64 + # runners and aarch64 on the GitHub ARM runner, which matches + # the upstream release asset naming exactly. + ARCH=$(uname -m) + + wget -q "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-${ARCH}.AppImage" + wget -q "https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-${ARCH}.AppImage" + chmod +x linuxdeploy*.AppImage + + # Convert ico to png for AppImage icon + convert cmicon.ico[0] -resize 256x256 cleanmodels-qt.png 2>/dev/null || \ + cp icons/clean-button.png cleanmodels-qt.png + + # Build AppImage + export QMAKE="$QT_ROOT_DIR/bin/qmake" + export LD_LIBRARY_PATH="$QT_ROOT_DIR/lib:$LD_LIBRARY_PATH" + "./linuxdeploy-${ARCH}.AppImage" \ + --appdir AppDir \ + --icon-file cleanmodels-qt.png \ + --desktop-file cleanmodels-qt.desktop \ + --plugin qt \ + --output appimage + + mv CleanModels_Qt*.AppImage cleanmodels-qt-${{ matrix.name }}.AppImage + + - name: Deploy (macOS) + if: runner.os == 'macOS' + run: | + "$QT_ROOT_DIR/bin/macdeployqt" build/cleanmodels-qt.app -verbose=1 + + # Bundle the cleanmodels CLI inside the .app next to the GUI + # binary. macdeployqt is finished by this point so we won't + # confuse it with extra Mach-O files. The CLI is unsigned โ€” + # users opening the .app for the first time may need to clear + # quarantine from the bundled binary the same way they do for + # the GUI itself. + install -m 755 cli-bundle/cleanmodels build/cleanmodels-qt.app/Contents/MacOS/cleanmodels + + mkdir dist + cp -R build/cleanmodels-qt.app dist/ + cd dist && zip -r ../cleanmodels-qt-${{ matrix.name }}.zip cleanmodels-qt.app + + - name: Deploy (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + mkdir dist\cleanmodels-qt + Copy-Item build\Release\cleanmodels-qt.exe dist\cleanmodels-qt\ + & "$env:QT_ROOT_DIR\bin\windeployqt.exe" dist\cleanmodels-qt\cleanmodels-qt.exe --release --no-translations --no-system-d3d-compiler + + # Bundle the cleanmodels CLI next to the GUI inside the same + # zip, so the GUI's "alongside binary" lookup finds it without + # extra setup on the user's part. + Copy-Item cli-bundle\cleanmodels.exe dist\cleanmodels-qt\ + + Compress-Archive -Path dist\cleanmodels-qt -DestinationPath cleanmodels-qt-${{ matrix.name }}.zip + + - name: Upload artifact + uses: actions/upload-artifact@v6 + with: + name: cleanmodels-qt-${{ matrix.name }} + path: | + cleanmodels-qt-*.AppImage + cleanmodels-qt-*.zip + if-no-files-found: error release: - name: Uploading Release + if: startsWith(github.ref, 'refs/tags/v') needs: build runs-on: ubuntu-latest - if: github.event_name == 'push' + permissions: + contents: write steps: - - uses: actions/checkout@v2 - - - name: Download Artifacts - uses: actions/download-artifact@v2 - with: - path: ${{ github.workspace }}/zips - - - name: Tag With Version and Release - uses: "marvinpinto/action-automatic-releases@latest" - if: github.event_name == 'push' - with: - repo_token: "${{ secrets.GITHUB_TOKEN }}" - automatic_release_tag: "build0.8.0-HEAD" - prerelease: false - draft: false - title: "build0.8.0-HEAD" - files: | - ./zips/cleanmodels-qt.windows-static.amd64.zip - ./zips/cleanmodels-qt.windows-shared.amd64.zip - ./zips/cleanmodels-qt.linux-static.amd64.zip - ./zips/cleanmodels-qt.linux-shared.amd64.zip - ./zips/cleanmodels-qt.macosx-static.amd64.zip - ./zips/cleanmodels-qt.macosx-shared.amd64.zip - - - name: Tag With Latest and Release - uses: "marvinpinto/action-automatic-releases@latest" - if: github.event_name == 'push' - with: - repo_token: "${{ secrets.GITHUB_TOKEN }}" - automatic_release_tag: "latest" - prerelease: false - draft: false - title: "build0.8.0-HEAD" - files: | - ./zips/cleanmodels-qt.windows-static.amd64.zip - ./zips/cleanmodels-qt.windows-shared.amd64.zip - ./zips/cleanmodels-qt.linux-static.amd64.zip - ./zips/cleanmodels-qt.linux-shared.amd64.zip - ./zips/cleanmodels-qt.macosx-static.amd64.zip - ./zips/cleanmodels-qt.macosx-shared.amd64.zip \ No newline at end of file + - name: Download all artifacts + uses: actions/download-artifact@v7 + with: + merge-multiple: true + + - name: Create release + uses: softprops/action-gh-release@v3 + with: + generate_release_notes: true + files: | + cleanmodels-qt-*.AppImage + cleanmodels-qt-*.zip diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 057e9b5..aa618f9 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,72 +1,43 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" +name: CodeQL on: push: - branches: [ main ] + branches: [main] pull_request: - # The branches below must be a subset of the branches above - branches: [ main ] + branches: [main] schedule: - - cron: '20 19 * * 3' + - cron: "20 19 * * 3" + +permissions: + security-events: write jobs: analyze: name: Analyze runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - language: [ 'cpp' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] - # Learn more: - # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - - name: Qt Dependencies - run: | - sudo apt-get update - sudo apt-get install qtbase5-dev - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 - - # โ„น๏ธ Command-line programs to run using the OS shell. - # ๐Ÿ“š https://git.io/JvXDl - - # โœ๏ธ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + - uses: actions/checkout@v6 + + - name: Install Qt6 + uses: jurplel/install-qt-action@v4 + with: + version: "6.7.*" + cache: true + + - name: Install OpenGL + run: | + sudo apt-get update + sudo apt-get install -y libgl1-mesa-dev libglu1-mesa-dev + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: cpp + + - name: Configure and build + run: | + cmake -B build -DCMAKE_BUILD_TYPE=Debug + cmake --build build --parallel + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.gitignore b/.gitignore index 45b947c..050bbb2 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ ui_*.h *.jsc Makefile* *build-* +build/ *.qm *.prl release diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..95fdd4c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,33 @@ +# cleanmodels-qt + +Qt6/C++17 desktop GUI for [cleanmodels](https://github.com/plenarius/cleanmodels), a tool to fix and clean NWN:EE MDL model files. Launches the Go CLI as a subprocess and consumes its `--json-lines` output. + +## Architecture +- **mainwindow.cpp** โ€” UI construction (programmatic, not Designer-driven), settings, directory management +- **mainwindow_clean.cpp** โ€” QProcess lifecycle, JSON-lines parsing, file table + debug log updates +- **modelviewport.cpp** โ€” QOpenGLWidget subclass for 3D model preview (OpenGL 3.3 Core) +- **renderer.cpp** โ€” Scene graph traversal, shader compilation, mesh/texture upload +- **mdlscene.cpp** โ€” Lightweight ASCII MDL parser (preview-only, not the authoritative parser) +- **gpumesh.cpp / gputexture.cpp** โ€” GPU resource wrappers +- **camera.cpp** โ€” Orbit camera with mouse interaction +- **fsmodel.cpp** โ€” QFileSystemModel wrapper for directory completion + +## Design Context + +### Users +NWN community modders who build, fix, and maintain 3D model files (.mdl) for Neverwinter Nights: Enhanced Edition. These users range from experienced 3D artists to module builders with limited technical background. They work in a pipeline that includes 3D editors (NWMax/Blender), the game's toolset, and cleanmodels as the quality gate before assets ship. + +### Brand Personality +**Fast, precise, flexible.** The tool should feel like a sharp instrument โ€” quick to produce correct results, trustworthy in its output, and accommodating of the wide variety of models found in the NWN community (old, malformed, hand-edited, tool-exported). It never corrupts data and always explains what it changed. + +### Aesthetic Direction +- **Qt GUI**: Native OS look โ€” blend in with the platform rather than imposing a custom theme. The interface should feel like a well-made professional utility, not a game launcher or a toy. Polished and intentional, but never flashy. +- **3D Viewport**: Critical to the workflow. Users rely on it to visually verify fixes. It should be clear, responsive, and show geometry accurately. Grid, wireframe, and reference model overlay are first-class features. +- **Anti-references**: Not Blender-dark, not game-fantasy-themed, not web-app-styled. Think: native file manager meets engineering tool. + +### Design Principles +1. **Speed is a feature** โ€” The legacy Prolog tool was too slow. Every operation should feel instant on single models, and batch processing should saturate I/O, not CPU. Never block the UI. +2. **Correctness over convenience** โ€” Binary output must match the game's own compiler. Checks should catch real issues, not generate noise. When in doubt, preserve the original data. +3. **Approachable complexity** โ€” The tool has deep options (pivot repair, tilefade slicing, walkmesh material remapping) but non-technical modders should be able to hit "All Fixes" and get a good result. Progressive disclosure: simple defaults, advanced options collapsed. +4. **Explain what changed** โ€” Every fix, warning, and repair should produce a human-readable message. The debug log and per-file fix counts are the user's audit trail. +5. **Stay focused** โ€” NWN MDL files only. No scope creep into other formats, game engines, or unrelated tooling. diff --git a/CMakeLists.txt b/CMakeLists.txt index 82ba38c..9e4a589 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,33 +1,41 @@ -cmake_minimum_required(VERSION 3.10) -project(cleanmodels-qt) +cmake_minimum_required(VERSION 3.16) +project(cleanmodels-qt LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_INCLUDE_CURRENT_DIR ON) -set(QT_COMPONENTS Core Widgets Gui) - -if(DEFINED ENV{STATIC_BUILD} AND NOT $ENV{STATIC_BUILD} STREQUAL "") - if(DEFINED ENV{QT_STATIC_PATH} AND NOT $ENV{QT_STATIC_PATH} STREQUAL "") - set(CMAKE_PREFIX_PATH "$ENV{QT_STATIC_PATH}/lib/cmake/Qt5") - set(QT_ROOT "$ENV{QT_STATIC_PATH}/lib/cmake/") - include("./CMakeModules/Qt.cmake") - endif() -endif() - -find_package(Qt5 COMPONENTS ${QT_COMPONENTS} REQUIRED) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) set(CMAKE_AUTOUIC ON) -add_executable(${PROJECT_NAME} main.cpp mainwindow.cpp mainwindow_clean.cpp fsmodel.cpp icons.qrc prolog_files.qrc mainwindow.ui) +find_package(Qt6 REQUIRED COMPONENTS Core Widgets Gui OpenGL OpenGLWidgets) + +add_executable(${PROJECT_NAME} + main.cpp + mainwindow.cpp + mainwindow_clean.cpp + fsmodel.cpp + camera.cpp + mdlscene.cpp + mdlanimationplayer.cpp + gpumesh.cpp + gputexture.cpp + renderer.cpp + modelviewport.cpp + constants.h + icons.qrc + mainwindow.ui +) -include_directories(BEFORE ${CMAKE_CURRENT_BINARY_DIR}) -target_link_libraries(${PROJECT_NAME} Qt5::Core Qt5::Widgets Qt5::Gui) +target_link_libraries(${PROJECT_NAME} PRIVATE + Qt6::Core Qt6::Widgets Qt6::Gui Qt6::OpenGL Qt6::OpenGLWidgets) + +if(APPLE) + set_target_properties(${PROJECT_NAME} PROPERTIES MACOSX_BUNDLE TRUE) +endif() +if(WIN32) + set_target_properties(${PROJECT_NAME} PROPERTIES WIN32_EXECUTABLE TRUE) +endif() -if(DEFINED ENV{STATIC_BUILD} AND NOT $ENV{STATIC_BUILD} STREQUAL "") - if(DEFINED ENV{QT_STATIC_PATH} AND NOT $ENV{QT_STATIC_PATH} STREQUAL "") - qt5_import_plugins(${PROJECT_NAME} - EXCLUDE_BY_TYPE imageformats - ) - endif() -endif() \ No newline at end of file +install(TARGETS ${PROJECT_NAME} DESTINATION bin) diff --git a/CMakeModules/Qt.cmake b/CMakeModules/Qt.cmake deleted file mode 100644 index 408ad35..0000000 --- a/CMakeModules/Qt.cmake +++ /dev/null @@ -1,386 +0,0 @@ - -# -# This is a helper script that I use to ease creation of CMake based project using -# Qt. It especially takes care of patching Qt's exported targets when you're using -# a statically built version of Qt (check Google for "Qt static link windows") to -# see what I mean :) -# -# Basically this script is used like so: -# -# ``` -# set (QT_ROOT "path/to/your/Qt/library/folder/lib/cmake") -# set (QT_COMPONENTS Widgets) -# include ("./CMakeUtils/Qt.cmake") -# ``` -# -# Here is more detailed explanation of the variables available to configure the script: -# -# QT_ROOT -# Can be either the folder where the CMake exported targets are located, -# or a list of such folders. This is passed to `find_package` as `HINTS`, -# optionally combined with the versions set in QT_VERSIONS. -# -# QT_VERSIONS -# Optional list of version strings. If this is set, the script will iterate -# on all the paths in QT_ROOT, and will combine each one with each version -# string set in QT_VERSIONS. The resulting strings are passed as `HINTS` to -# find_package. -# -# QT_SUFFIX -# When used, it's passed to `find_package` as `PATH_SUFFIXES`. It can be -# either a single string or a list. -# -# QT_MIN_VERSION -# Optional minimum version number. If omitted, defaults to `5`. -# -# QT_COMPONENTS -# The components you want to load. -# -# A more complete example, if you're working on a multiplatform project: -# -# ``` -# # The paths on your various platforms or build machines: -# set (QT_ROOT -# "C:/Qt" -# "D:/Qt" -# "/usr/localQt" -# ) -# -# # The various versions you support, from the most recent to the oldest -# set (QT_VERSIONS "5.13.2" "5.13.1" "5.13.0") -# -# # The suffixes (those are kinda standard when installing Qt from the online installer) -# set (QT_SUFFIX -# "msvc2017_64/lib/cmake" -# "gcc_64/lib/cmake" -# "clang_64/lib/cmake" -# ) -# -# # Minimum version and components to use -# set (QT_MIN_VERSION 5.13) -# set (QT_COMPONENTS Widgets) -# -# # And finally include this script -# include ("./CMakeUtils/Qt.cmake") -# ``` -# - -# -# If we have some versions, combine them to the roots -# -if (DEFINED QT_VERSIONS) - set (ROOTS ${QT_ROOT}) - foreach (ROOT IN ITEMS ${ROOTS}) - foreach (VERSION IN ITEMS ${QT_VERSIONS}) - list (APPEND QT_ROOT "${ROOT}/${VERSION}") - endforeach () - endforeach () -endif () - -# -# Find Qt. -# -find_path (QT_DIR Qt5 - HINTS - ${QT_ROOT} - PATH_SUFFIXES - ${QT_SUFFIX} -) - -# -# Log / Error -# -if (NOT QT_DIR) - message (FATAL_ERROR "Couldn't find Qt. Use QT_ROOT variable to point to a valid Qt install. ${QT_ROOT}") -else () - message (STATUS "Found Qt in '${QT_DIR}'") -endif () - -# -# Find our Qt components -# -if (NOT QT_MIN_VERSION) - set (QT_MIN_VERSION 5) -endif () -set (CMAKE_PREFIX_PATH ${QT_DIR}) -find_package (Qt5 ${QT_MIN_VERSION} COMPONENTS ${QT_COMPONENTS} REQUIRED) - -# -# Check if Qt is a shared or static library -# -get_target_property (QT_CORE_TARGET_TYPE Qt5::Core TYPE) -if (QT_CORE_TARGET_TYPE STREQUAL STATIC_LIBRARY) - set (QT_STATIC ON) - message (STATUS "Using a static Qt library, patching exported targets...") -else () - set (QT_STATIC OFF) -endif () - -# -# When using static build, exported Qt targets miss a awefull lot of dependencies (on Windows -# at least, didn't check the other platforms) so to avoid bothering, patch Qt5::Widgets -# -if (QT_STATIC) - - # - # Set a few paths - # - set (QT_LIB_DIR "${QT_DIR}/..") - set (QT_PLUGIN_DIR "${QT_DIR}/../../plugins") - - # - # Qt5::QWindowsIntegrationPlugin - # - if (TARGET Qt5::QWindowsIntegrationPlugin) - - # find additional components needed by the windows platform plugin - find_package (Qt5 5 - COMPONENTS - EventDispatcherSupport - FontDatabaseSupport - ThemeSupport - WindowsUIAutomationSupport - REQUIRED - ) - - # configure direct dependencies of the plugin - target_link_libraries(Qt5::QWindowsIntegrationPlugin - INTERFACE - # Qt targets - Qt5::EventDispatcherSupport - Qt5::FontDatabaseSupport - Qt5::ThemeSupport - Qt5::WindowsUIAutomationSupport - - # Windows libs - Dwmapi.lib - Imm32.lib - Wtsapi32.lib - ) - - endif () - - # - # Qt5::QXcbGlxIntegrationPlugin - # - if (TARGET Qt5::QXcbGlxIntegrationPlugin) - - # find additional components needed by the linux platform plugin - find_package (Qt5 5 - COMPONENTS - GlxSupport - XcbQpa - REQUIRED - ) - - # configure direct dependencies of the plugin - target_link_libraries(Qt5::QXcbGlxIntegrationPlugin - INTERFACE - # Qt targets - Qt5::XcbQpa - Qt5::GlxSupport - Qt5::QXcbIntegrationPlugin - - # System libs - xcb-glx - ) - - endif () - - # - # Qt5::QWindowsVistaStylePlugin - # - if (TARGET Qt5::QWindowsVistaStylePlugin) - - target_link_libraries(Qt5::QWindowsVistaStylePlugin - INTERFACE - # System libs - UxTheme.lib - ) - - endif () - - # - # Qt5::QGtk3ThemePlugin - # - if (TARGET Qt5::QGtk3ThemePlugin) - - target_link_libraries(Qt5::QGtk3ThemePlugin - INTERFACE - # System libs - gtk-3 - gdk-3 - pango-1.0 - gobject-2.0 - ) - - endif () - - # - # Qt5::FontDatabaseSupport - # - if (TARGET Qt5::FontDatabaseSupport) - - target_link_libraries(Qt5::FontDatabaseSupport - INTERFACE - # Qt libs - $<$:${QT_LIB_DIR}/qtfreetype.lib> - ) - - endif () - - # - # Qt5::Gui - # - if (TARGET Qt5::Gui) - - target_link_libraries(Qt5::Gui - INTERFACE - # Qt targets - $<$:Qt5::QWindowsIntegrationPlugin> - - # Qt libs - $<$:${QT_LIB_DIR}/qtharfbuzz.lib> - $<$:${QT_LIB_DIR}/qtlibpng.lib> - ) - - endif () - - # - # Qt5::Core - # - if (TARGET Qt5::Core) - - target_link_libraries(Qt5::Core - INTERFACE - # Qt libs - $<$:${QT_LIB_DIR}/qtpcre2.lib> - - # Windows libs - $<$:Netapi32.lib> - $<$:Ws2_32.lib> - $<$:UserEnv.lib> - $<$:Version.lib> - $<$:Winmm.lib> - ) - - target_compile_definitions (Qt5::Core - INTERFACE - # Remove debug stuff from Qt - $<$:QT_NO_DEBUG> - $<$:QT_NO_DEBUG_OUTPUT> - $<$:QT_NO_INFO_OUTPUT> - $<$:QT_NO_WARNING_OUTPUT> - - # Since Qt was built in release, we need to match it on Windows - $<$:_ITERATOR_DEBUG_LEVEL=0> - ) - - endif () - - # - # Jpeg plugin - # - if (TARGET Qt5::QJpegPlugin) - - target_link_libraries(Qt5::QJpegPlugin - INTERFACE - $<$:-ljpeg> - ) - - endif () - - # - # Tiff plugin - # - if (TARGET Qt5::QTiffPlugin) - - target_link_libraries(Qt5::QTiffPlugin - INTERFACE - $<$:-ltiff> - ) - - endif () - - # - # Qt5::Svg - # - if (TARGET Qt5::Svg) - - target_link_libraries(Qt5::Svg - INTERFACE - # Qt targets - $<$:Qt5::QSvgIconPlugin> - $<$:Qt5::QSvgPlugin> - ) - - endif () - -endif () - -# -# Usage: install_qt_target (TARGET QMLDIR <...>) -# -# This does 2 things: First, invoke the usual `install` CMake command. -# Then it will invoke the `windeployqt` helper tool to gather every dll, qml script, -# plugin, etc. that is needed to have a standalone target. -# -# The deploy step will only happen when linking against a dynamic Qt library, -# and on Windows (for the moment) -# -# TARGET : Name of the target to install -# QMLDIR : Root QML folder. This is passed to the deploy helper as --qmldir -# <...> : The rest of the arguments are forwarded to the normal CMake `install` command -# -function (install_qt_target TARGET QMLDIR) - - # Install the target at the root of the install prefix - install (TARGETS ${TARGET} - ${ARGN} - ) - - # Windows + shared Qt : deploy - if (WIN32 AND NOT QT_STATIC) - - # find the windeployqt utility - find_program (QT_DEPLOY_TOOL - NAMES - windeployqt - HINTS - ${QT_DIR}/../../bin - ) - - # error check - if (NOT QT_DEPLOY_TOOL) - message (WARNING "Couldn't find windeployqt tool in ${QT_DIR} - Deployment will not work.") - return () - endif () - - # and execute the command - install (CODE " - message (STATUS \"Deploying ${TARGET}\") - - execute_process ( - COMMAND ${QT_DEPLOY_TOOL} --qmldir \"${QMLDIR}\" \"${CMAKE_INSTALL_PREFIX}/${TARGET}${CMAKE_EXECUTABLE_SUFFIX}\" - --no-compiler-runtime - --no-opengl-sw - --no-webkit2 - --no-system-d3d-compiler - --no-translations - --no-qmltooling - WORKING_DIRECTORY ${QT_DIR}/bin - RESULT_VARIABLE QT_DEPLOY_RESULT - ERROR_VARIABLE QT_DEPLOY_OUTPUT - OUTPUT_VARIABLE QT_DEPLOY_OUTPUT - ) - - if (NOT \${QT_DEPLOY_RESULT} EQUAL 0) - message (WARNING \"Deployment failed with code \${QT_DEPLOY_RESULT}\${QT_DEPLOY_OUTPUT}\") - else () - message (STATUS \"Deployment done\") - endif () - ") - - endif () - -endfunction () diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..f146784 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,309 @@ +--- +name: cleanmodels-qt +description: Native desktop GUI for the cleanmodels NWN MDL toolchain +colors: + log-info: '#3498db' + log-success: '#27ae60' + log-error: '#e74c3c' + log-warning: '#e67e22' + log-command: '#95a5a6' + log-fix-applied: '#2ecc71' + log-sev-error: '#e74c3c' + log-sev-warning: '#f39c12' + log-sev-info: '#7f8c8d' + log-invalid-path: '#FF0000' + viewport-bg: '#2E3340' + viewport-grid: '#595966' + viewport-reference: '#6699E6' +typography: + body: + fontFamily: 'system-ui' + fontWeight: 400 + label: + fontFamily: 'system-ui' + fontWeight: 700 + log: + fontFamily: 'ui-monospace, SF Mono, Menlo, Consolas, Liberation Mono, monospace' + fontSize: '0.9em' + drawer: + fontFamily: 'system-ui' + fontSize: '0.85em' + fontWeight: 700 +spacing: + root-margin: '8px' + default-spacing: '6px' + compact-spacing: '4px' + section-gap: '14px' + indent-left: '16px' + group-margin-h: '8px' + group-margin-top: '6px' + group-margin-bottom: '6px' + sidebar-top-pad: '8px' +components: + clean-button: + height: '36px' + clean-button-running: + textColor: '{colors.log-error}' + typography: '{typography.label}' + table-row: + height: '24px' + sidebar: + width: '380px' + collapse-header: + typography: '{typography.label}' + padding: '4px 0' + invalid-path-input: + textColor: '{colors.log-invalid-path}' +--- + +# Design System: cleanmodels-qt + +## 1. Overview + +**Creative North Star: "The Engineering Utility"** + +cleanmodels-qt is a Qt6 desktop application that looks and feels native on Linux, macOS, and Windows. It is built from standard Qt widgets with minimal custom styling โ€” a model viewer plus a CLI dashboard plus a structured options panel, fused into a single resizable window. The visual language is the platform's, not the app's. + +The reference points are professional engineering and content tools that live alongside an IDE on the same desktop: a native file manager, a CMake GUI, a glTF inspector, the OS's own image viewer. The anti-references are everything in PRODUCT.md: Blender-dark themes, fantasy launchers, web-app card grids, gradient buttons, glass surfaces. + +The window has three primary surfaces. The **viewport** (OpenGL, ~50% of the window when expanded) is where the user sees the loaded model. The **sidebar** (320โ€“480px, default 380px) is where the user configures the run. The **findings + log area** (bottom split) is where results stream in. None of these is decorative; all three reflect the same CLI run. + +**Key Characteristics:** + +- Native Qt widgets and native palette. Custom QSS is the exception, never the default. +- Viewport-first layout: the 3D view is the largest surface when the user has a model loaded. +- Color is reserved for severity in the log, the viewport, and the invalid-path indicator. Nothing else gets tinted. +- Spacing is small and dense (root margin 8px, default spacing 6px) so the window stays compact at common laptop resolutions. +- Tokens are pixels. Qt's logical-pixel scaling handles HiDPI; we don't redefine sizes per density. + +## 2. Colors + +The palette is defensive. The OS provides the chrome (window frame, scrollbars, buttons, menus, splitters); the app provides severity colors for the log, three colors for the OpenGL viewport, and one red for invalid input paths. Everything else is the system palette. + +### Primary + +The application has no "primary" accent color. The system theme provides whatever accent the user has configured at the OS level. Trying to add a brand colour on top of that would fight the OS โ€” and PRODUCT.md is explicit that we don't. + +### Log Severity (HTML-coloured spans in the QTextEdit log) + +These are the colours emitted into the log via inline HTML. They map cleanmodels' severity model onto the log surface. Hex values are normative; the log uses them literally. + +- **Info** (`#3498db`): General informational messages from the GUI ("Selected directory: ...", "Found N models"). +- **Success** (`#27ae60`): Successful operations and completion summaries. +- **Error** (`#e74c3c`): Errors that the user must read. Also used for `SevError` from the CLI. +- **Warning** (`#e67e22`): Warnings worth noting but not blocking. +- **Command** (`#95a5a6`): The CLI invocation line itself, dimmed so it doesn't compete with results. +- **Fix Applied** (`#2ecc71`): A subtly brighter green that distinguishes "we changed something" from "the operation succeeded" in the log stream. +- **Severity: Info** (`#7f8c8d`): `SevInfo` items from the CLI. Lower contrast on purpose; surfaces only under verbose. +- **Severity: Warning** (`#f39c12`): `SevWarning` items from the CLI. A warmer yellow than the GUI's own Warning so the source is visible. + +### Viewport + +OpenGL clear/grid/overlay colors. RGB floats in code; hex equivalents documented here. + +- **Background** (`#2E3340`, RGB(0.18, 0.20, 0.25)): Dark slate-blue clear color. Cool and neutral; lets light-coloured models sit comfortably without glare. +- **Grid** (`#595966`, RGB(0.35, 0.35, 0.40)): Mid-gray grid lines. Visible against the background but not so bright they compete with the model wireframe. +- **Reference Model Overlay** (`#6699E6`, RGB(0.4, 0.6, 0.9)): Light blue. Distinct from any common diffuse colour so the user can tell at a glance what's the loaded model and what's the reference. + +### Invalid State + +- **Invalid Path** (`#FF0000`): Pure saturated red, applied as a `color:` style on the input directory's `QLineEdit`. The only place in the app where pure-saturated red is used. Its rarity is the point. + +### Named Rules + +**The Native-Palette Rule.** The application does not define its own primary, secondary, or accent colour. The OS provides those. Any time custom colour is used, it is severity (in the log), spatial (in the viewport), or validation (the invalid-path red). + +**The Severity-Colour Rule.** Colours in the log are reserved for severity. Don't tint filenames, paths, or counts; they live in the system foreground colour. The severity tag is the only coloured token on a log line. + +## 3. Typography + +The application does not ship its own fonts. Qt resolves the system UI font; the app applies bold and size deltas via `QFont` properties (`setBold`, `setPointSizeF`). + +**UI Font:** System UI font (Qt resolves: Segoe UI on Windows, San Francisco on macOS, the configured GTK/Plasma font on Linux). + +**Log Font:** System monospace, sized at 0.9ร— the body. Used in the raw log pane and any pre-formatted CLI output. + +### Hierarchy + +- **Headers** (bold, system size): Section labels in the sidebar; collapsible-section header buttons. Bold is the only weight contrast inside the sidebar. +- **Body** (regular, system size): All other labels, controls, list rows, table cells. +- **Drawer** (bold, 0.85ร— system size): Compact header text for collapsed/condensed UI strips. +- **Log** (monospace, 0.9ร— system size): The raw log `QTextEdit`. Smaller and monospaced so dense streaming output doesn't dominate the window. +- **Clean Button (running)** (bold, system size, color `{colors.log-error}`): The Clean action button while a run is in flight. Bold red text signals "operation in progress, this is the cancel target". + +### Named Rules + +**The System-Font Rule.** Don't bundle fonts. Don't override the family. Bold and size deltas are the only typography knobs. The app reads correctly in whatever font the user has configured. + +**The Sparing-Bold Rule.** Bold is reserved for: section headers, collapsible-section toggles, and one transient state (Clean-while-running). Body text is never bold. Buttons get their weight from the OS theme. + +### Font-relative chrome + +Anything that frames text โ€” buttons, table cells, drawer headers, progress bars, status sliver โ€” is sized in *em* multiples of `fontMetrics().height()` (vertical) or `horizontalAdvance("0")` (horizontal). The multipliers live in `constants.h` under `Layout::*Em` / `Layout::*Chars`, the conversion to pixels happens via `Layout::emH(fm, em)` / `Layout::emW(fm, chars)` in `metrics.h`. + +| Token | Multiplier | Used for | +|---|---|---| +| `CleanButtonHeightEm` | 2.0 ร— line-height | Bold "Clean" button | +| `TableRowHeightEm` | 1.4 ร— line-height | File-table rows | +| `SidebarToggleHeightEm` | 1.2 ร— line-height | "Hide Sidebar" flat button | +| `DrawerTitleHeightEm` | 1.5 ร— line-height | Raw-log drawer title bar | +| `DrawerCloseSizeEm` | 1.2 ร— line-height | Drawer close button (square) | +| `DetailPanelMinHeightEm` | 5.0 ร— line-height | Detail panel min height (~5 body lines) | +| `StatusProgressHeightEm` | 0.7 ร— line-height | Status-bar progress sliver | +| `TableColumnSizeChars` | 9 ร— digit-width | "Size" column ("1234.56 KB") | +| `TableColumnStatusChars` | 11 ร— digit-width | "Status" column | +| `TableColumnFixesChars` | 9 ร— digit-width | "Fixes" column | +| `TableColumnTimeChars` | 9 ร— digit-width | "Time" column | +| `RefModelComboWidthChars` | 14 ร— digit-width | Reference-model combo min width | +| `StatusProgressWidthChars` | 11 ร— digit-width | Status-bar progress sliver width | + +The point: a user who bumps the system font from 12pt to 16pt, or runs at 200% scale, gets buttons / table cells / drawers that grow proportionally instead of clipping at 96-DPI defaults. Each call site reads the *widget's own* `font()`, so when a child uses a smaller / monospace / bold variant the chrome adapts to that font, not to the application default. + +The two non-em pixel sizes that remain are `SidebarMinWidth`/`SidebarMaxWidth`. Those are gross structural breakpoints โ€” they decide when the sidebar layout collapses or stretches โ€” not text frames, so they stay in pixels by design. + +## 4. Elevation + +Flat by default. Qt's native widgets carry whatever shadow vocabulary the OS provides โ€” window drop-shadow, popup shadow on menus, focus ring on inputs. The application does not add shadow tokens of its own. + +### Spatial cues that aren't shadows + +- **Splitter handles** between viewport / sidebar / log: thin, OS-native, draggable. +- **`QGroupBox` framing** for sidebar option groups: native border, optional title. +- **Collapsible sections**: header is a `QPushButton` (text-aligned left, transparent in collapsed state), content area shows/hides under it. + +### Named Rules + +**The Flat-By-Default Rule.** No custom shadows, no glow tokens, no glass. Depth comes from native widget chrome only. If a panel or card needs a shadow, the layout is wrong. + +## 5. Components + +### Window Layout + +A single `QMainWindow` with three resizable regions wired through `QSplitter`: + +- **Viewport** (top-left): OpenGL widget with model preview. Min height ~280px; user-resizable. +- **Sidebar** (right): Options panel. Min 320, max 480, default 380px (`Layout::SidebarMinWidth/MaxWidth/DefaultWidth`). +- **Findings + Log area** (bottom): Findings tree on the left, raw log `QTextEdit` on the right (also splittable). Either side can collapse. + +Persisted: window geometry, splitter positions, sidebar width, log/findings visibility, sidebar visibility. + +### Sidebar Sections + +The sidebar is a vertical stack of `QGroupBox`-framed sections, each containing a `QFormLayout`. Sections include: + +- Directories (input / output / pattern) +- Classification (combo) +- Fix toggles (validate, strip degenerate, animations, pivots, tilefade, AABB, reparent, wrap-root, split-multiedge) +- Rescaling (X/Y/Z) +- Snap controls (vertex / TVert) +- Render / shadow overrides +- Tile-specific operations (water, foliage, splotches, ground, chamfer, raise/lower, walkmesh remap) +- Texture/EE cleanup +- Camera sensitivities + +Layout tokens (`Layout::*`): + +- Root margin: 8px +- Default spacing: 6px +- Compact spacing: 4px +- Section gap: 14px +- Indent left: 16px +- GroupBox margins: 8px horizontal, 6px top, 6px bottom + +### Collapsible Sections + +A `QPushButton` styled to look like a section header (`text-align: left; padding: 4px 0; font-weight: bold;`) toggles visibility of a `QWidget` content panel. Used for "Advanced" sub-groups inside larger sections so the default sidebar stays scannable. + +### Clean Button + +The primary action. Triggers a `cleanmodels` subprocess with the current options. + +- **Resting**: `QPushButton`, min-height = `Layout::CleanButtonHeightEm` ร— line-height (~36px at 12pt), default Qt style. Label: "Clean". +- **Starting**: button is disabled briefly between `QProcess::start()` and `QProcess::started`. No restyle โ€” just disabled. There is nothing to abort yet. +- **Running**: same button, restyled via QSS to `color: ; font-weight: bold;`. The colour is read from `LogColor::Error` (not hard-coded), so the running state stays in lockstep with the rest of the severity palette. Label changes to a cancel-style cue. Bold red signals "this is the in-flight target". Applied in `MainWindow::onCleanStarted`. +- **Failed to start**: button re-enabled with original "Clean" label and a critical message in the detail panel. Applied in `MainWindow::onCleanProcessError` when `err == QProcess::FailedToStart`. +- **Done**: stylesheet cleared back to default (`m_cleanButton->setStyleSheet("")`). + +### Subprocess Lifecycle (Async) + +Every CLI invocation in the Qt app is signal-driven; no slot ever calls `QProcess::waitForStarted` or `waitForFinished` on a path the user can take. The UI thread stays responsive even when the CLI binary is slow to launch (cold cache, antivirus interception, network filesystem). + +Two patterns appear in the code: + +- **Long-running batches (`MainWindow::doClean`)**: `m_pCleanProcess` is a member, parented to the window. Persistent connections in the constructor wire `&QProcess::started` โ†’ `onCleanStarted`, `&QProcess::errorOccurred` โ†’ `onCleanProcessError`, and `&QProcess::finished` โ†’ `onCleanFinished`. `doClean` disables the button, calls `start()`, and returns. The state machine fires the appropriate slot when the OS resolves the start. +- **One-shot decompiles (`ModelViewport::decompileAsync`)**: a transient `QProcess` parented to the viewport with a `QTimer` watchdog (`CliDefaults::ProcessTimeoutMs`, 10s). On `finished`, the helper invokes a success or error callback and `deleteLater`s the process. The watchdog kills runaway CLI processes so a wedged decompile never leaks. Callers (`previewFile`, `loadReferenceFile`) capture a `QPointer` so the callback is a no-op if the widget is destroyed mid-operation. + +### Input Directory Field + +A `QLineEdit` plus a "Browse" `QPushButton`. The line edit gets one piece of custom styling: when the path doesn't exist or isn't readable, `setStyleSheet("color: ")` paints the text pure red. Cleared back to default once the path is valid. The colour is read from `LogColor::InvalidPath`, never hard-coded inline. + +### Findings Tree + +A `QTreeView` listing per-file results streamed from the CLI's JSON-lines events. Row height fixed at 24px (`Layout::TableRowHeight`) for density. Columns: file, status, fixes, warnings, errors. Severity icons or coloured indicators per row. + +### Raw Log Pane + +A read-only `QTextEdit` rendered in the system monospace font at 0.9ร— body size. Streams every CLI line plus the GUI's own info/success/error messages, each wrapped in HTML span with the matching `LogColor::*` token. + +The HTML-fragment templates live in `loghtml.h` (`LogHtml::span`, `logLine`, `detailLine`, `listItem`, `preBlock`). Call sites pass the colour token and the inner HTML; the helper handles the escape sequence. This is the only place those literal `` for native paragraph spacing, or switching to CSS classes) is one diff instead of fourteen. Callers remain responsible for `.toHtmlEscaped()` on any user-derived string before it reaches a helper โ€” the helpers are template assemblers, not sanitisers. + +### 3D Viewport + +A `QOpenGLWidget`-backed scene. OpenGL context: 3.3 core, 24-bit depth, 4ร— MSAA (`GLDefaults::*`). + +- **Clear color**: `#2E3340` (`ViewportColor::Bg*`). +- **Grid**: ground plane at `#595966` (`ViewportColor::Grid*`). +- **Reference model overlay**: `#6699E6` (`ViewportColor::Ref*`) when a reference is loaded for comparison. +- **Camera**: orbit, pan, zoom. Sensitivities persisted (`Setting::CameraRotSens`, `CameraPanScale`, `CameraZoomFactor`). + +### Combo Options + +Every dropdown uses `Options::*`'s `{label, cliValue}` pairs from `constants.h` so the display string and the CLI flag value stay coupled at compile time. Empty `cliValue` = "no selection / default" and the flag is omitted from the subprocess argv. + +### Keyboard Surface + +Every primary action has a keyboard binding so the app is usable from a screen reader, a kiosk without a mouse, or a developer who lives in shortcuts. The bindings are split between menu-bound `QAction`s (declared in `mainwindow.ui` so they appear inline in the menus) and free-standing `QShortcut`s registered in code. + +| Shortcut | Action | Source | +|---|---|---| +| `F5` | Run cleanmodels (also the Clean button's primary shortcut) | `m_cleanButton->setShortcut` | +| `Ctrl+Return` / `Ctrl+Enter` | Run cleanmodels (alternate; F5 is unreachable on FN-locked laptops) | `QShortcut` | +| `Esc` | Abort an in-flight run (no-op when idle) | `QShortcut` | +| `Ctrl+O` | Load preset | `actionLoadPreset` | +| `Ctrl+S` | Save preset | `actionSavePreset` | +| `Ctrl+Q` | Quit | `actionQuit` | +| `Ctrl+L` | Toggle raw log drawer | `actionToggleRawLog` | +| `Ctrl+Shift+S` | Toggle sidebar | `actionToggleSidebar` | +| `F1` | Help | `actionHelp` | + +`Esc` and the `Ctrl+Return` family use `Qt::WindowShortcut` context so modal dialogs above this window (file pickers, message boxes) handle their own `Esc` first. The Clean and Abort shortcuts share a single physical control: the shortcut callbacks both call `doClean()`, which switches between start and abort based on `m_bCleanRunning`. + +### Accessible Names + +Every primary widget gets an `accessibleName` (and where useful, `accessibleDescription`) so VoiceOver / Narrator / Orca can announce it without falling back to the class name. Tooltips on the same widgets repeat the action plus the keyboard binding, so mouse users get the same information visually. + +Set on: `m_cleanButton`, `m_indirButton`, `m_outdirButton`, `m_inDirectory`, `m_outDirectory`, `m_filePattern`, `m_classificationCombo`, `m_filesTable`, `m_detailPanel`, `m_debugTextBrowser`, `m_viewport`, `m_refBrowseBtn`, `m_refModelCombo`, `m_refModelCheck`, `m_wireframeCheck`, `m_gridCheck`, `m_sidebarToggleBtn`. Severity icons and statistics labels rely on their text content for accessibility (the announcement is the same). + +## 6. Do's and Don'ts + +### Do + +- **Do** use native Qt widgets and the OS palette. Custom QSS is the exception, never the default. +- **Do** route every operation through the `cleanmodels` CLI subprocess. JSON-lines events are the contract; the human-readable log is for the user, not for parsing. +- **Do** persist every option, geometry, splitter, and visibility toggle in `QSettings`. Users don't re-set the tool. +- **Do** centralise tokens in `constants.h` (`Layout`, `LogColor`, `ViewportColor`, `Options`). Hard-coded magic numbers in `mainwindow.cpp` are a bug. +- **Do** colour log entries by severity using `LogColor::*`. Wrap inline in HTML spans on the way to the `QTextEdit`. +- **Do** keep the 3D viewport accurate and responsive. It's the visual verification surface; lag or wrong geometry erodes trust. + +### Don't + +- **Don't** ship a custom dark theme, neon palette, or custom widget style. PRODUCT.md is explicit: not Blender-dark, not game-fantasy-themed, not web-app-styled. +- **Don't** add card grids with rounded shadows, hero metrics, gradient buttons, or glass surfaces. The app is not a SaaS dashboard. +- **Don't** reimplement validation or repair logic in C++. If the GUI needs a result the CLI doesn't surface, extend the CLI's JSON output and consume it here. +- **Don't** parse the human-readable log to detect outcomes. The JSON-lines schema is the contract. +- **Don't** add custom shadow tokens or a glow vocabulary. Native widget chrome is the only depth in the app. +- **Don't** introduce app-defined primary / accent colours that compete with the OS theme. Severity, viewport spatial cues, and the invalid-path red are the only sanctioned custom colours. +- **Don't** colour anything in the log other than severity. Filenames, counts, and paths use the system foreground. +- **Don't** add wizards, modal-on-modal flows, or multi-step setup screens. The main window does the job. +- **Don't** hard-code `setStyleSheet` strings outside the small set of sanctioned states (Clean-running, invalid-path) and the collapsible-section header. New styled states need a token in `constants.h` or a discussion first. Borderless / transparent buttons should reach for `setFlat(true)` before they reach for QSS. +- **Don't** bundle fonts or override the system UI font family. Bold and size deltas are the only typography knobs. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index be5c247..8972c46 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,63 @@ # cleanmodels-qt -QT front end for the [cleanmodels-cli](https://github.com/plenarius/cleanmodels), a tool to fix and clean NWN:EE mdl files. -# Download +Qt6 GUI for [cleanmodels](https://github.com/plenarius/cleanmodels), a tool to validate, repair, compile, and decompile Neverwinter Nights MDL model files. -You can download binaries from the [Releases](https://github.com/plenarius/cleanmodels-qt/releases) tab. +![Qt6](https://img.shields.io/badge/Qt-6.7-41cd52) ![C++17](https://img.shields.io/badge/C%2B%2B-17-blue) -# Building +## Features + +- Batch processing with per-file progress, status, and detailed findings +- 3D model preview with OpenGL viewport +- Full access to all cleanmodels repair, transform, and tile operations +- Compile (ASCII to binary) and decompile (binary to ASCII) modes +- Persistent settings for directories, options, and camera + +## Requirements + +- **cleanmodels** CLI binary โ€” download from [cleanmodels releases](https://github.com/plenarius/cleanmodels/releases) and place it in your PATH or alongside this application + +> **Note:** cleanmodels-qt requires the Go-based cleanmodels v4+. The legacy Prolog CLI is not supported. +> If you need the old Prolog-based GUI, see [cleanmodels-qt v0.8.0](https://github.com/plenarius/cleanmodels-qt/releases/tag/build0.8.0-HEAD). + +## Download + +Grab the latest binary for your platform from the [Releases](https://github.com/plenarius/cleanmodels-qt/releases) page. + +## Building from source + +Requires Qt 6.7+ with OpenGL support, CMake 3.16+, and a C++17 compiler. + +### Linux (Ubuntu/Debian) -On Ubuntu/Debian ``` -$ git clone https://github.com/plenarius/cleanmodels-qt -$ cd cleanmodels-qt -$ mkdir build -$ sudo apt-get install qtbase5-dev -$ cd build && cmake .. && make +sudo apt-get install qt6-base-dev qt6-opengl-dev libgl1-mesa-dev +git clone https://github.com/plenarius/cleanmodels-qt +cd cleanmodels-qt +cmake -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build --parallel ``` -This will create an executable `cleanmodels-qt` binary in your current folder. +### macOS + +``` +brew install qt@6 +git clone https://github.com/plenarius/cleanmodels-qt +cd cleanmodels-qt +cmake -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH=$(brew --prefix qt@6) +cmake --build build --parallel +``` + +### Windows + +Install Qt 6.7+ via the [Qt Online Installer](https://www.qt.io/download-qt-installer), then: + +``` +git clone https://github.com/plenarius/cleanmodels-qt +cd cleanmodels-qt +cmake -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH=C:\Qt\6.7.0\msvc2022_64 +cmake --build build --config Release +``` + +## License + +MIT diff --git a/camera.cpp b/camera.cpp new file mode 100644 index 0000000..a2f9b81 --- /dev/null +++ b/camera.cpp @@ -0,0 +1,67 @@ +#include "camera.h" +#include +#include + +Camera::Camera() = default; + +QVector3D Camera::position() const +{ + float x = m_distance * cosf(m_pitch) * cosf(m_yaw); + float y = m_distance * cosf(m_pitch) * sinf(m_yaw); + float z = m_distance * sinf(m_pitch); + return m_target + QVector3D(x, y, z); +} + +QMatrix4x4 Camera::viewMatrix() const +{ + QMatrix4x4 view; + view.lookAt(position(), m_target, QVector3D(0, 0, 1)); + return view; +} + +QMatrix4x4 Camera::projectionMatrix() const +{ + QMatrix4x4 proj; + proj.perspective(m_fov, m_aspect, m_near, m_far); + return proj; +} + +void Camera::rotate(float dx, float dy) +{ + m_yaw -= dx * m_rotSens; + m_pitch += dy * m_rotSens; + m_pitch = std::clamp(m_pitch, -1.5f, 1.5f); +} + +void Camera::pan(float dx, float dy) +{ + QVector3D forward = (m_target - position()).normalized(); + QVector3D up(0, 0, 1); + QVector3D right = QVector3D::crossProduct(forward, up).normalized(); + QVector3D camUp = QVector3D::crossProduct(right, forward).normalized(); + + float scale = m_distance * m_panScale; + m_target += right * (-dx * scale) + camUp * (dy * scale); +} + +void Camera::zoom(float delta) +{ + m_distance *= (1.0f - delta * m_zoomFactor); + m_distance = std::clamp(m_distance, 0.1f, 10000.0f); +} + +void Camera::focusOnBounds(const QVector3D &bmin, const QVector3D &bmax) +{ + m_target = (bmin + bmax) * 0.5f; + float extent = (bmax - bmin).length(); + if (extent < 0.001f) + extent = 10.0f; + m_distance = extent * 1.5f; + m_near = m_distance * 0.001f; + m_far = m_distance * 100.0f; +} + +void Camera::setAspectRatio(float aspect) +{ + m_aspect = aspect; +} diff --git a/camera.h b/camera.h new file mode 100644 index 0000000..eef8a6d --- /dev/null +++ b/camera.h @@ -0,0 +1,50 @@ +#ifndef CAMERA_H +#define CAMERA_H + +#include +#include + +class Camera +{ +public: + Camera(); + + void rotate(float dx, float dy); + void pan(float dx, float dy); + void zoom(float delta); + void focusOnBounds(const QVector3D &bmin, const QVector3D &bmax); + void setAspectRatio(float aspect); + + QMatrix4x4 viewMatrix() const; + QMatrix4x4 projectionMatrix() const; + QVector3D position() const; + + float rotationSensitivity() const { return m_rotSens; } + void setRotationSensitivity(float v) { m_rotSens = v; } + + float panScale() const { return m_panScale; } + void setPanScale(float v) { m_panScale = v; } + + float zoomFactor() const { return m_zoomFactor; } + void setZoomFactor(float v) { m_zoomFactor = v; } + + static constexpr float DefaultRotationSensitivity = 0.01f; + static constexpr float DefaultPanScale = 0.002f; + static constexpr float DefaultZoomFactor = 0.1f; + +private: + QVector3D m_target; + float m_distance = 10.0f; + float m_yaw = 0.0f; + float m_pitch = 0.6f; + float m_fov = 45.0f; + float m_aspect = 1.0f; + float m_near = 0.01f; + float m_far = 5000.0f; + + float m_rotSens = DefaultRotationSensitivity; + float m_panScale = DefaultPanScale; + float m_zoomFactor = DefaultZoomFactor; +}; + +#endif diff --git a/cleanmodels-qt.desktop b/cleanmodels-qt.desktop new file mode 100644 index 0000000..d135542 --- /dev/null +++ b/cleanmodels-qt.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=CleanModels Qt +Comment=Validate, repair, compile, and decompile NWN MDL model files +Exec=cleanmodels-qt +Icon=cleanmodels-qt +Categories=Utility;Development; +Terminal=false diff --git a/cleanmodels-qt.pro b/cleanmodels-qt.pro deleted file mode 100644 index d4f12c2..0000000 --- a/cleanmodels-qt.pro +++ /dev/null @@ -1,39 +0,0 @@ -QT += core gui - -greaterThan(QT_MAJOR_VERSION, 4): QT += widgets - -TARGET = cleanmodels-qt -TEMPLATE = app - -# The following define makes your compiler emit warnings if you use -# any feature of Qt which has been marked as deprecated (the exact warnings -# depend on your compiler). Please consult the documentation of the -# deprecated API in order to know how to port your code away from it. -DEFINES += QT_DEPRECATED_WARNINGS - -# You can also make your code fail to compile if you use deprecated APIs. -# In order to do so, uncomment the following line. -# You can also select to disable deprecated APIs only up to a certain version of Qt. -#DEFINES += QT_DISABLE_DEPRECATED_BEFORE=0x060000 # disables all the APIs deprecated before Qt 6.0.0 - -SOURCES += \ - fsmodel.cpp \ - main.cpp \ - mainwindow.cpp \ - mainwindow_clean.cpp - -HEADERS += \ - fsmodel.h \ - mainwindow.h - -FORMS += \ - mainwindow.ui - -DISTFILES += - -RESOURCES += \ - prolog_files.qrc \ - icons.qrc - - -RC_FILE = cleanmodels-qt_resource.rc diff --git a/constants.h b/constants.h new file mode 100644 index 0000000..e96e483 --- /dev/null +++ b/constants.h @@ -0,0 +1,326 @@ +#ifndef CONSTANTS_H +#define CONSTANTS_H + +#include + +// --------------------------------------------------------------------------- +// Log output HTML colors +// --------------------------------------------------------------------------- +namespace LogColor { + constexpr const char *Info = "#3498db"; + constexpr const char *Success = "#27ae60"; + constexpr const char *Error = "#e74c3c"; + constexpr const char *Warning = "#e67e22"; + constexpr const char *Command = "#95a5a6"; + + constexpr const char *FixApplied = "#2ecc71"; + constexpr const char *SevError = "#e74c3c"; + constexpr const char *SevWarning = "#f39c12"; + constexpr const char *SevInfo = "#7f8c8d"; + + constexpr const char *InvalidPath = "#FF0000"; + constexpr const char *Placeholder = "gray"; +} + +// --------------------------------------------------------------------------- +// Report size limits (must match Worker and CLI constraints) +// --------------------------------------------------------------------------- +namespace ReportLimits { + constexpr qint64 MaxFileSize = 10 * 1024 * 1024; // 10 MB per file + constexpr qint64 MaxTotalSize = 25 * 1024 * 1024; // 25 MB total +} + +// --------------------------------------------------------------------------- +// OpenGL surface defaults +// --------------------------------------------------------------------------- +namespace GLDefaults { + constexpr int MajorVersion = 3; + constexpr int MinorVersion = 3; + constexpr int DepthBits = 24; + constexpr int Samples = 4; +} + +// --------------------------------------------------------------------------- +// CLI subprocess +// --------------------------------------------------------------------------- +namespace CliDefaults { + constexpr int ProcessTimeoutMs = 10000; +#ifdef Q_OS_WIN + constexpr const char *BinaryName = "cleanmodels.exe"; +#else + constexpr const char *BinaryName = "cleanmodels"; +#endif +} + +// --------------------------------------------------------------------------- +// CLI flags โ€” single source of truth for flag strings passed to cleanmodels +// --------------------------------------------------------------------------- +namespace CliCommand { + constexpr const char *Repair = "repair"; + constexpr const char *Decompile = "decompile"; + constexpr const char *Compile = "compile"; +} + +namespace CliFlag { + constexpr const char *JsonLines = "--json-lines"; + constexpr const char *Check = "--check"; + constexpr const char *StripDegenerate = "--strip-degenerate"; + constexpr const char *FixAnimations = "--fix-animations"; + constexpr const char *FixPivots = "--fix-pivots"; + constexpr const char *FixTilefade = "--fix-tilefade"; + constexpr const char *TilefadeZ = "--tilefade-z"; + constexpr const char *FixAabb = "--fix-aabb"; + constexpr const char *ReparentChildren = "--reparent-children"; + constexpr const char *WrapRoot = "--wrap-root"; + constexpr const char *SplitMultiedge = "--split-multiedge"; + constexpr const char *Scale = "--scale"; + constexpr const char *ScaleX = "--scale-x"; + constexpr const char *ScaleY = "--scale-y"; + constexpr const char *ScaleZ = "--scale-z"; + constexpr const char *Classification = "--classification"; + constexpr const char *Snap = "--snap"; + constexpr const char *TvertSnap = "--tvert-snap"; + constexpr const char *Render = "--render"; + constexpr const char *Shadow = "--shadow"; + constexpr const char *ForceWhite = "--force-white"; + constexpr const char *MergeByBitmap = "--merge-by-bitmap"; + constexpr const char *CullInvisible = "--cull-invisible"; + constexpr const char *PlaceableTrans = "--placeable-transparency"; + constexpr const char *TransparencyKey = "--transparency-key"; + constexpr const char *PivotAllowSplit = "--pivot-allow-split"; + constexpr const char *PivotBelowZ0 = "--pivot-below-z0"; + constexpr const char *PivotMoveBad = "--pivot-move-bad"; + constexpr const char *PivotSmoothing = "--pivot-smoothing"; + constexpr const char *PivotMinFaces = "--pivot-min-faces"; + constexpr const char *PivotSplitFirst = "--pivot-split-first"; + constexpr const char *Water = "--water"; + constexpr const char *WaterKey = "--water-key"; + constexpr const char *DynamicWater = "--dynamic-water"; + constexpr const char *WaveHeight = "--wave-height"; + constexpr const char *RotateWater = "--rotate-water"; + constexpr const char *RetileWater = "--retile-water"; + constexpr const char *Foliage = "--foliage"; + constexpr const char *FoliageKey = "--foliage-key"; + constexpr const char *Splotch = "--splotch"; + constexpr const char *SplotchKey = "--splotch-key"; + constexpr const char *RotateGround = "--rotate-ground"; + constexpr const char *GroundKey = "--ground-key"; + constexpr const char *Chamfer = "--chamfer"; + constexpr const char *RetileGround = "--retile-ground"; + constexpr const char *RaiseLower = "--raise-lower"; + constexpr const char *RaiseAmount = "--raise-amount"; + constexpr const char *RemapWalkmesh = "--remap-walkmesh-material"; + constexpr const char *TilefadeUndo = "--tilefade-undo"; + constexpr const char *IncludeChecks = "--include-checks"; + constexpr const char *ExcludeChecks = "--exclude-checks"; + constexpr const char *StandardizeTexture0 = "--standardize-texture0"; + constexpr const char *StripEEExtras = "--strip-ee-extras"; +} + +// --------------------------------------------------------------------------- +// QSettings keys โ€” single source of truth for persistent option keys +// --------------------------------------------------------------------------- +namespace Setting { + constexpr const char *Geometry = "geometry"; + constexpr const char *OptionsGroup = "options"; + + constexpr const char *InDir = "indir"; + constexpr const char *OutDir = "outdir"; + constexpr const char *Pattern = "pattern"; + constexpr const char *Classification = "classification"; + + constexpr const char *AllFixes = "all_fixes"; + constexpr const char *FixValidate = "fix_validate"; + constexpr const char *FixStripDegen = "fix_strip_degen"; + constexpr const char *FixAnimations = "fix_animations"; + constexpr const char *FixPivots = "fix_pivots"; + constexpr const char *FixTilefade = "fix_tilefade"; + constexpr const char *FixAabb = "fix_aabb"; + constexpr const char *FixReparent = "fix_reparent"; + constexpr const char *FixWrapRoot = "fix_wrap_root"; + constexpr const char *FixSplitMultiedge = "fix_split_multiedge"; + + constexpr const char *RescaleX = "rescale_x"; + constexpr const char *RescaleY = "rescale_y"; + constexpr const char *RescaleZ = "rescale_z"; + constexpr const char *Snap = "snap"; + constexpr const char *TvertSnap = "tvert_snap"; + constexpr const char *RenderMode = "render"; + constexpr const char *ShadowMode = "shadow"; + constexpr const char *ForceWhite = "force_white"; + constexpr const char *MergeByBitmap = "merge_by_bitmap"; + constexpr const char *InvisibleMeshCull = "invisible_mesh_cull"; + constexpr const char *PlaceableTrans = "placeable_with_transparency"; + constexpr const char *TransparencyKey = "transparency_key"; + + constexpr const char *TilefadeUndo = "tilefade_undo"; + constexpr const char *SliceHeight = "slice_height"; + constexpr const char *DoWater = "do_water"; + constexpr const char *DynamicWater = "dynamic_water"; + constexpr const char *WaveHeight = "wave_height"; + constexpr const char *WaterKey = "water_key"; + constexpr const char *RotateWater = "rotate_water"; + constexpr const char *TileWater = "tile_water"; + constexpr const char *Foliage = "foliage"; + constexpr const char *FoliageKey = "foliage_key"; + constexpr const char *AnimateSplotches = "animate_splotches"; + constexpr const char *SplotchKey = "splotch_key"; + constexpr const char *RotateGround = "rotate_ground"; + constexpr const char *ChamferMode = "chamfer"; + constexpr const char *TileGround = "tile_ground"; + constexpr const char *GroundKey = "ground_key"; + constexpr const char *TileRaise = "tile_raise"; + constexpr const char *TileRaiseAmount = "tile_raise_amount"; + constexpr const char *MapAabbMaterial = "map_aabb_material"; + constexpr const char *MapAabbFrom = "map_aabb_from"; + constexpr const char *MapAabbTo = "map_aabb_to"; + + constexpr const char *AllowSplit = "allow_split"; + constexpr const char *PivotsBelowZ0 = "pivots_below_z0"; + constexpr const char *MoveBadPivots = "move_bad_pivots"; + constexpr const char *SmoothingGroups = "smoothing_groups"; + constexpr const char *MinSize = "min_size"; + constexpr const char *SplitFirst = "split_first"; + + constexpr const char *StandardizeTexture0 = "standardize_texture0"; + constexpr const char *StripEEExtras = "strip_ee_extras"; + + constexpr const char *CameraRotSens = "camera_rotation_sensitivity"; + constexpr const char *CameraPanScale = "camera_pan_scale"; + constexpr const char *CameraZoomFactor = "camera_zoom_factor"; + + constexpr const char *SidebarVisible = "sidebar_visible"; + constexpr const char *RawLogVisible = "raw_log_visible"; +} + +// --------------------------------------------------------------------------- +// Combo option sets โ€” {display label, CLI value} pairs +// First item's CLI value is empty string = "no selection / default" +// --------------------------------------------------------------------------- +struct ComboOption { + const char *label; + const char *cliValue; +}; + +namespace Options { + constexpr ComboOption Classification[] = { + {"Automatic", ""}, {"Character", "CHARACTER"}, {"Door", "DOOR"}, + {"Effect", "EFFECT"}, {"Item", "ITEM"}, {"Tile", "TILE"} + }; + constexpr ComboOption Snap[] = { + {"None", ""}, {"Binary (1/128)", "binary"}, + {"Decimal (0.01)", "decimal"}, {"Fine (0.001)", "fine"} + }; + constexpr ComboOption TvertSnap[] = { + {"None", ""}, {"256", "256"}, {"512", "512"}, {"1024", "1024"} + }; + constexpr ComboOption RenderOverride[] = { + {"Default", ""}, {"All", "all"}, {"None", "none"} + }; + constexpr ComboOption ShadowOverride[] = { + {"Default", ""}, {"All", "all"}, {"None", "none"} + }; + constexpr ComboOption PivotBelowZ0[] = { + {"Disallow", "disallow"}, {"Allow", "allow"}, {"Slice", "slice"} + }; + constexpr ComboOption PivotMoveBad[] = { + {"No", "no"}, {"Top", "top"}, {"Middle", "middle"}, {"Bottom", "bottom"} + }; + constexpr ComboOption PivotSmoothing[] = { + {"Use", "use"}, {"Protect", "protect"}, {"Ignore", "ignore"} + }; + constexpr ComboOption PivotSplitFirst[] = { + {"Convex", "convex"}, {"Concave", "concave"} + }; + constexpr ComboOption DynamicWater[] = { + {"Yes", "yes"}, {"No", "no"}, {"Wavy", "wavy"} + }; + constexpr ComboOption RotateToggle[] = { + {"No change", ""}, {"1", "1"}, {"0", "0"} + }; + constexpr ComboOption RetileSize[] = { + {"No change", ""}, {"1x1", "1"}, {"2x2", "2"}, {"3x3", "3"} + }; + constexpr ComboOption Foliage[] = { + {"No change", ""}, {"Tilefade", "tilefade"}, {"Animate", "animate"}, + {"De-animate", "de-animate"}, {"Ignore", "ignore"} + }; + constexpr ComboOption Chamfer[] = { + {"No change", ""}, {"Add", "add"}, {"Delete", "delete"} + }; + constexpr ComboOption RaiseLower[] = { + {"None", ""}, {"Raise", "raise"}, {"Lower", "lower"} + }; +} + +// --------------------------------------------------------------------------- +// Layout tokens +// --------------------------------------------------------------------------- +namespace Layout { + constexpr int RootMargin = 8; + constexpr int DefaultSpacing = 6; + constexpr int CompactSpacing = 4; + constexpr int TightSpacing = 2; + constexpr int SectionGap = 14; + constexpr int IndentLeft = 16; + + constexpr int GroupMarginH = 8; + constexpr int GroupMarginTop = 6; + constexpr int GroupMarginBottom = 6; + + constexpr int SidebarTopPad = 8; + constexpr int SidebarMinWidth = 320; + constexpr int SidebarMaxWidth = 480; + constexpr int SidebarDefaultWidth = 380; + + // โ”€โ”€ Font-relative chrome (em multipliers) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // All sizes that frame *text* are expressed as multiples of the widget's + // own line height (`fontMetrics().height()`) or a "0" advance, not in + // pixels. The point: a user who bumps the system font from 12pt to 16pt, + // or runs at 200% scale, gets buttons / table cells / drawers that grow + // proportionally instead of clipping at 96-DPI defaults. + // + // Use Layout::emH(fm, X) for vertical (line-height-based) sizing and + // Layout::emW(fm, N) for horizontal (zero-advance-based) sizing. + constexpr double CleanButtonHeightEm = 2.0; // ~36px at 12pt + constexpr double TableRowHeightEm = 1.4; // ~24px at 12pt + constexpr double SidebarToggleHeightEm = 1.2; + constexpr double DrawerTitleHeightEm = 1.5; + constexpr double DrawerCloseSizeEm = 1.2; + constexpr double DetailPanelMinHeightEm = 5.0; // โ‰ˆ5 body lines + constexpr double StatusProgressHeightEm = 0.7; // sliver in status bar + + // Horizontal em (multiplied by zero-advance, so they read as "N digits"): + constexpr double TableColumnSizeChars = 9.0; // "1234.56 KB" + constexpr double TableColumnStatusChars = 11.0; // "Processing" + constexpr double TableColumnFixesChars = 9.0; // "12 (3 info)" + constexpr double TableColumnTimeChars = 9.0; // "00:00.000" + constexpr double RefModelComboWidthChars = 14.0; + constexpr double StatusProgressWidthChars = 11.0; + + // Legacy pixel aliases (computed from the em multipliers at startup-time + // when only an approximate value is needed and a QFontMetrics isn't + // immediately available). Real widgets should call emH/emW with their + // own font metrics via the helpers in metrics.h. + constexpr int CleanButtonHeight = 36; + constexpr int TableRowHeight = 24; +} + +// --------------------------------------------------------------------------- +// Viewport colors +// --------------------------------------------------------------------------- +namespace ViewportColor { + constexpr float BgR = 0.18f; + constexpr float BgG = 0.20f; + constexpr float BgB = 0.25f; + + constexpr float GridR = 0.35f; + constexpr float GridG = 0.35f; + constexpr float GridB = 0.40f; + + constexpr float RefR = 0.4f; + constexpr float RefG = 0.6f; + constexpr float RefB = 0.9f; +} + +#endif // CONSTANTS_H diff --git a/glcontextguard.h b/glcontextguard.h new file mode 100644 index 0000000..06993bc --- /dev/null +++ b/glcontextguard.h @@ -0,0 +1,40 @@ +#ifndef GLCONTEXTGUARD_H +#define GLCONTEXTGUARD_H + +#include + +// RAII guard that pairs makeCurrent() with doneCurrent() across an +// OpenGL operation. Handles destructor-time cleanup so the GL context +// is restored even if the called code throws or returns early. Lives +// in its own header so any translation unit that drives a +// QOpenGLWidget can use the same idiom without depending on +// modelviewport.h (which would force a transitive include of +// QOpenGLFunctions_3_3_Core, the renderer, the scene, and the +// animation player just to RAII-wrap one make/done pair). +// +// Non-copyable and non-movable on purpose: a single scope must own +// the make/done pairing. Permitting copies/moves would either double- +// release (two guards both call doneCurrent on destruction) or +// silently transfer ownership across scopes, defeating the point of +// having a guard at all. +class GlContextGuard +{ +public: + explicit GlContextGuard(QOpenGLWidget *w) : m_w(w) + { + if (m_w) m_w->makeCurrent(); + } + ~GlContextGuard() + { + if (m_w) m_w->doneCurrent(); + } + GlContextGuard(const GlContextGuard &) = delete; + GlContextGuard &operator=(const GlContextGuard &) = delete; + GlContextGuard(GlContextGuard &&) = delete; + GlContextGuard &operator=(GlContextGuard &&) = delete; + +private: + QOpenGLWidget *m_w; +}; + +#endif diff --git a/gpumesh.cpp b/gpumesh.cpp new file mode 100644 index 0000000..be67b28 --- /dev/null +++ b/gpumesh.cpp @@ -0,0 +1,130 @@ +#include "gpumesh.h" +#include +#include + +GpuMesh::~GpuMesh() = default; + +// Member-wise std::swap on every field. This shape lets adding a new GPU +// handle (or counter) be a one-line edit to the field list and has the +// move ops pick it up automatically โ€” previously each new field required +// manual edits to the move-ctor, move-assign, and destroy() in lockstep, +// and m_vertexCount was missed in destroy() the first time it was added. +GpuMesh::GpuMesh(GpuMesh &&other) noexcept +{ + std::swap(m_vao, other.m_vao); + std::swap(m_vbo, other.m_vbo); + std::swap(m_ebo, other.m_ebo); + std::swap(m_indexCount, other.m_indexCount); + std::swap(m_vertexCount, other.m_vertexCount); + std::swap(m_warnedSizeMismatch, other.m_warnedSizeMismatch); +} + +GpuMesh &GpuMesh::operator=(GpuMesh &&other) noexcept +{ + if (this != &other) { + std::swap(m_vao, other.m_vao); + std::swap(m_vbo, other.m_vbo); + std::swap(m_ebo, other.m_ebo); + std::swap(m_indexCount, other.m_indexCount); + std::swap(m_vertexCount, other.m_vertexCount); + std::swap(m_warnedSizeMismatch, other.m_warnedSizeMismatch); + } + return *this; +} + +void GpuMesh::upload(QOpenGLFunctions_3_3_Core *gl, + const QVector &vertices, + const QVector &indices) +{ + m_indexCount = indices.size(); + m_vertexCount = vertices.size(); + m_warnedSizeMismatch = false; + + gl->glGenVertexArrays(1, &m_vao); + gl->glGenBuffers(1, &m_vbo); + gl->glGenBuffers(1, &m_ebo); + + gl->glBindVertexArray(m_vao); + + gl->glBindBuffer(GL_ARRAY_BUFFER, m_vbo); + gl->glBufferData(GL_ARRAY_BUFFER, + vertices.size() * static_cast(sizeof(Vertex)), + vertices.constData(), GL_DYNAMIC_DRAW); + + gl->glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_ebo); + gl->glBufferData(GL_ELEMENT_ARRAY_BUFFER, + indices.size() * static_cast(sizeof(uint32_t)), + indices.constData(), GL_STATIC_DRAW); + + // location 0: position (vec3) + gl->glEnableVertexAttribArray(0); + gl->glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), + reinterpret_cast(offsetof(Vertex, pos))); + + // location 1: normal (vec3) + gl->glEnableVertexAttribArray(1); + gl->glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), + reinterpret_cast(offsetof(Vertex, normal))); + + // location 2: uv (vec2) + gl->glEnableVertexAttribArray(2); + gl->glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), + reinterpret_cast(offsetof(Vertex, uv))); + + gl->glBindVertexArray(0); +} + +void GpuMesh::updateVertices(QOpenGLFunctions_3_3_Core *gl, + const QVector &vertices) +{ + if (!gl || !m_vbo) + return; + if (vertices.size() != m_vertexCount) { + // A count mismatch means the caller's per-frame topology has + // drifted from the upload-time topology. Surface it once per + // mesh so the symptom ("the mesh stops animating") doesn't + // masquerade as a different bug, but stay quiet thereafter so + // a wedged mesh doesn't spam stderr at 60 Hz. + if (!m_warnedSizeMismatch) { + qWarning() << "GpuMesh::updateVertices count mismatch:" + << vertices.size() << "vs upload" << m_vertexCount + << "โ€” suppressing further warnings for this mesh"; + m_warnedSizeMismatch = true; + } + return; + } + gl->glBindBuffer(GL_ARRAY_BUFFER, m_vbo); + gl->glBufferSubData(GL_ARRAY_BUFFER, 0, + vertices.size() * static_cast(sizeof(Vertex)), + vertices.constData()); + gl->glBindBuffer(GL_ARRAY_BUFFER, 0); +} + +void GpuMesh::draw(QOpenGLFunctions_3_3_Core *gl) const +{ + if (!m_vao || m_indexCount == 0) + return; + gl->glBindVertexArray(m_vao); + gl->glDrawElements(GL_TRIANGLES, m_indexCount, GL_UNSIGNED_INT, nullptr); + gl->glBindVertexArray(0); +} + +void GpuMesh::drawWireframe(QOpenGLFunctions_3_3_Core *gl) const +{ + if (!m_vao || m_indexCount == 0) + return; + gl->glPolygonMode(GL_FRONT_AND_BACK, GL_LINE); + gl->glBindVertexArray(m_vao); + gl->glDrawElements(GL_TRIANGLES, m_indexCount, GL_UNSIGNED_INT, nullptr); + gl->glBindVertexArray(0); + gl->glPolygonMode(GL_FRONT_AND_BACK, GL_FILL); +} + +void GpuMesh::destroy(QOpenGLFunctions_3_3_Core *gl) +{ + if (m_vao) { gl->glDeleteVertexArrays(1, &m_vao); m_vao = 0; } + if (m_vbo) { gl->glDeleteBuffers(1, &m_vbo); m_vbo = 0; } + if (m_ebo) { gl->glDeleteBuffers(1, &m_ebo); m_ebo = 0; } + m_indexCount = 0; + m_vertexCount = 0; +} diff --git a/gpumesh.h b/gpumesh.h new file mode 100644 index 0000000..08d9266 --- /dev/null +++ b/gpumesh.h @@ -0,0 +1,60 @@ +#ifndef GPUMESH_H +#define GPUMESH_H + +#include +#include +#include +#include + +struct Vertex { + float pos[3]; + float normal[3]; + float uv[2]; +}; + +class GpuMesh +{ +public: + GpuMesh() = default; + ~GpuMesh(); + + GpuMesh(const GpuMesh &) = delete; + GpuMesh &operator=(const GpuMesh &) = delete; + GpuMesh(GpuMesh &&other) noexcept; + GpuMesh &operator=(GpuMesh &&other) noexcept; + + void upload(QOpenGLFunctions_3_3_Core *gl, + const QVector &vertices, + const QVector &indices); + + // In-place vertex data update for animated meshes. Vertex count must + // match the count from the most recent upload(); only the per-vertex + // data (positions/normals/uvs) is rewritten. No-op if `gl` is null, + // the mesh isn't valid, or the vertex count differs. + void updateVertices(QOpenGLFunctions_3_3_Core *gl, + const QVector &vertices); + + int vertexCount() const { return m_vertexCount; } + + void draw(QOpenGLFunctions_3_3_Core *gl) const; + void drawWireframe(QOpenGLFunctions_3_3_Core *gl) const; + void destroy(QOpenGLFunctions_3_3_Core *gl); + + bool isValid() const { return m_vao != 0; } + GLuint vao() const { return m_vao; } + int indexCount() const { return m_indexCount; } + +private: + GLuint m_vao = 0; + GLuint m_vbo = 0; + GLuint m_ebo = 0; + int m_indexCount = 0; + int m_vertexCount = 0; + // Per-mesh latch: a count-mismatch in updateVertices() once-per-mesh + // warns and then stays silent so a wedged animated mesh doesn't spam + // the log at 60 Hz. Reset on upload() so a re-upload of the same + // GpuMesh handle gets a fresh diagnostic chance. + bool m_warnedSizeMismatch = false; +}; + +#endif diff --git a/gputexture.cpp b/gputexture.cpp new file mode 100644 index 0000000..89bf695 --- /dev/null +++ b/gputexture.cpp @@ -0,0 +1,430 @@ +#include "gputexture.h" +#include +#include +#include +#include +#include + +namespace { + +// Maximum allowed dimension for either width or height. Lowered from 8192 to +// 4096 to bound peak per-texture working set at ~64 MiB (4096*4096*4 RGBA). +// NWN content is typically 256-2048 pixels per side; 4096 is comfortably above +// any realistic texture in production HAKs. +constexpr int kMaxTextureDim = 4096; +constexpr int64_t kMaxTexturePixels = static_cast(kMaxTextureDim) + * kMaxTextureDim; + +// Cap on the raw on-disk size we'll slurp before any header validation, to +// bound DoS via a hostile or accidental giant file. 64 MiB covers any sane +// texture (kMaxTextureDim^2 BC3-compressed = 16 MiB plus header and mipmap +// chain headroom). +constexpr qint64 kMaxTextureFileSize = 64LL * 1024 * 1024; + +inline uint16_t readU16LE(const unsigned char *p) +{ + return static_cast(p[0] | (p[1] << 8)); +} + +inline uint32_t readU32LE(const unsigned char *p) +{ + return static_cast(p[0]) + | (static_cast(p[1]) << 8) + | (static_cast(p[2]) << 16) + | (static_cast(p[3]) << 24); +} + +// Open + size-check + slurp. Refuses files larger than kMaxTextureFileSize +// before allocating, so a hostile file can't force us to allocate hundreds +// of MB of RAM just to fail validation a moment later. +bool readTextureFile(const QString &path, QByteArray &out) +{ + QFile f(path); + if (!f.open(QIODevice::ReadOnly)) + return false; + if (f.size() > kMaxTextureFileSize) { + qWarning() << "GpuTexture:" << path << "exceeds" + << kMaxTextureFileSize << "byte cap, refusing"; + return false; + } + out = f.readAll(); + return true; +} + +inline void unpack565(uint16_t c, unsigned char rgb[3]) +{ + int r = (c >> 11) & 0x1F; + int g = (c >> 5) & 0x3F; + int b = c & 0x1F; + // Bit-replication scaling (matches reference BC1 decoders). + rgb[0] = static_cast((r << 3) | (r >> 2)); + rgb[1] = static_cast((g << 2) | (g >> 4)); + rgb[2] = static_cast((b << 3) | (b >> 2)); +} + +// Decode the 4-colour BC1 RGB palette from an 8-byte BC1 colour block header. +// Always uses the 4-colour interpolation regardless of c0/c1 ordering. +// +// The BC1 spec defines a "punchthrough alpha" mode for c0 <= c1 where +// palette[3] is transparent black. Bioware DDS uses BC1 only for +// channelCount == 3 (RGB-only) content -- the alpha channel is irrelevant +// to the upload path, so transparent black would render as visible black +// artifacts on pixels that happened to land on palette index 3 in +// punchthrough mode (BC1 encoders pick that mode for compression-efficiency +// reasons, not just for transparency). 4-colour interpolation everywhere +// is therefore both correct for our use and avoids that artifact. +// +// BC3's colour half is spec'd as always-4-colour regardless of ordering, +// so this helper is correct there too. +void decodeBC1ColourPalette(const unsigned char *src, unsigned char palette[4][3]) +{ + unpack565(readU16LE(src), palette[0]); + unpack565(readU16LE(src + 2), palette[1]); + for (int k = 0; k < 3; ++k) { + palette[2][k] = static_cast( + (2 * palette[0][k] + palette[1][k]) / 3); + palette[3][k] = static_cast( + (palette[0][k] + 2 * palette[1][k]) / 3); + } +} + +// Decode one 4x4 BC1 block into RGBA pixels at (px, py) in `dst` of width `w`. +// Caller (loadBioDDS) guarantees `w` and `h` are multiples of 4 and that +// (px+4) <= w and (py+4) <= h, so per-pixel bounds checks are unnecessary. +void decodeBC1Block(const unsigned char *src, unsigned char *dst, + int w, int /*h*/, int px, int py) +{ + unsigned char palette[4][3]; + decodeBC1ColourPalette(src, palette); + uint32_t indices = readU32LE(src + 4); + + for (int j = 0; j < 4; ++j) { + for (int i = 0; i < 4; ++i) { + int idx = (indices >> ((j * 4 + i) * 2)) & 0x3; + unsigned char *p = dst + ((py + j) * w + (px + i)) * 4; + p[0] = palette[idx][0]; + p[1] = palette[idx][1]; + p[2] = palette[idx][2]; + p[3] = 255; + } + } +} + +// Decode one 4x4 BC3 block (BC4-style alpha + BC1-style colour) into RGBA +// pixels. Same alignment guarantees as decodeBC1Block. +void decodeBC3Block(const unsigned char *src, unsigned char *dst, + int w, int /*h*/, int px, int py) +{ + unsigned char a0 = src[0]; + unsigned char a1 = src[1]; + + unsigned char alphas[8]; + alphas[0] = a0; + alphas[1] = a1; + if (a0 > a1) { + for (int k = 1; k <= 6; ++k) + alphas[1 + k] = static_cast( + ((7 - k) * a0 + k * a1) / 7); + } else { + for (int k = 1; k <= 4; ++k) + alphas[1 + k] = static_cast( + ((5 - k) * a0 + k * a1) / 5); + alphas[6] = 0; + alphas[7] = 255; + } + + uint64_t aBits = 0; + for (int k = 0; k < 6; ++k) + aBits |= static_cast(src[2 + k]) << (k * 8); + + unsigned char palette[4][3]; + decodeBC1ColourPalette(src + 8, palette); + uint32_t cIndices = readU32LE(src + 12); + + for (int j = 0; j < 4; ++j) { + for (int i = 0; i < 4; ++i) { + int linear = j * 4 + i; + int aIdx = static_cast((aBits >> (linear * 3)) & 0x7); + int cIdx = (cIndices >> (linear * 2)) & 0x3; + unsigned char *p = dst + ((py + j) * w + (px + i)) * 4; + p[0] = palette[cIdx][0]; + p[1] = palette[cIdx][1]; + p[2] = palette[cIdx][2]; + p[3] = alphas[aIdx]; + } + } +} + +// In-place vertical flip of an RGBA buffer. Used by both loadTGA and +// loadBioDDS to converge on the bottom-up convention that the upload path +// (and the QImage fallback's .mirrored(false, true)) already uses. +void flipRowsRGBA(unsigned char *dst, int width, int height) +{ + int rowBytes = width * 4; + QByteArray scratch(rowBytes, '\0'); + for (int y = 0; y < height / 2; ++y) { + unsigned char *top = dst + y * rowBytes; + unsigned char *bot = dst + (height - 1 - y) * rowBytes; + std::memcpy(scratch.data(), top, rowBytes); + std::memcpy(top, bot, rowBytes); + std::memcpy(bot, scratch.data(), rowBytes); + } +} + +} // namespace + +bool GpuTexture::loadFromFile(QOpenGLFunctions_3_3_Core *gl, const QString &path) +{ + if (m_texture) { + gl->glDeleteTextures(1, &m_texture); + m_texture = 0; + } + + QString suffix = QFileInfo(path).suffix().toLower(); + + if (suffix == "tga") + return loadTGA(gl, path); + + if (suffix == "dds" && loadBioDDS(gl, path)) + return true; + + // Fallback: let QImage try (covers PNG/JPG/BMP, plus Microsoft DDS + // when the qt6-image-formats DDS plugin is bundled). The vertical + // flip here is the canonical orientation we converge on -- see + // flipRowsRGBA / loadTGA / loadBioDDS. + QImage img(path); + if (img.isNull()) { + qWarning() << "GpuTexture: cannot load" << path; + return false; + } + img = img.convertToFormat(QImage::Format_RGBA8888); + return uploadRGBA(gl, img.constBits(), img.width(), img.height(), true); +} + +bool GpuTexture::loadTGA(QOpenGLFunctions_3_3_Core *gl, const QString &path) +{ + QByteArray raw; + if (!readTextureFile(path, raw)) + return false; + + if (raw.size() < 18) { + qWarning() << "GpuTexture: TGA too small for header" << path; + return false; + } + + const auto *hdr = reinterpret_cast(raw.constData()); + int idLen = hdr[0]; + int cmapType = hdr[1]; + int imgType = hdr[2]; + int width = readU16LE(hdr + 12); + int height = readU16LE(hdr + 14); + int bpp = hdr[16]; + int descriptor = hdr[17]; + + Q_UNUSED(cmapType); + + if (imgType != 2 && imgType != 10) { + qWarning() << "GpuTexture: unsupported TGA type" << imgType; + return false; + } + + if (bpp != 24 && bpp != 32) { + qWarning() << "GpuTexture: unsupported TGA bpp" << bpp; + return false; + } + + int channels = bpp / 8; + int pixelDataOffset = 18 + idLen; + + int64_t pixelCount = static_cast(width) * height; + if (pixelCount <= 0 || pixelCount > kMaxTexturePixels) { + qWarning() << "GpuTexture: TGA dimensions too large or invalid" + << width << "x" << height; + return false; + } + + int64_t expectedSize = pixelDataOffset + pixelCount * channels; + + bool topOrigin = (descriptor & 0x20) != 0; + bool hasAlpha = (bpp == 32); + + QByteArray rgba(static_cast(pixelCount * 4), '\0'); + auto *dst = reinterpret_cast(rgba.data()); + const auto *src = reinterpret_cast(raw.constData()) + pixelDataOffset; + + if (imgType == 2) { + if (raw.size() < expectedSize) { + qWarning() << "GpuTexture: TGA pixel data truncated" << path; + return false; + } + + for (int64_t i = 0; i < pixelCount; ++i) { + dst[i * 4 + 0] = src[i * channels + 2]; // R (TGA stores BGR) + dst[i * 4 + 1] = src[i * channels + 1]; // G + dst[i * 4 + 2] = src[i * channels + 0]; // B + dst[i * 4 + 3] = hasAlpha ? src[i * channels + 3] : 255; + } + } else { + int pixel = 0; + int srcOff = pixelDataOffset; + + while (pixel < pixelCount && srcOff < raw.size()) { + unsigned char packet = static_cast(raw[srcOff++]); + int count = (packet & 0x7F) + 1; + + if (packet & 0x80) { + // RLE packet + if (srcOff + channels > raw.size()) break; + unsigned char b = raw[srcOff++]; + unsigned char g = raw[srcOff++]; + unsigned char r = raw[srcOff++]; + unsigned char a = hasAlpha ? raw[srcOff++] : 255; + + for (int j = 0; j < count && pixel < pixelCount; ++j, ++pixel) { + dst[pixel * 4 + 0] = r; + dst[pixel * 4 + 1] = g; + dst[pixel * 4 + 2] = b; + dst[pixel * 4 + 3] = a; + } + } else { + // Raw packet + for (int j = 0; j < count && pixel < pixelCount; ++j, ++pixel) { + if (srcOff + channels > raw.size()) break; + dst[pixel * 4 + 0] = raw[srcOff + 2]; // R + dst[pixel * 4 + 1] = raw[srcOff + 1]; // G + dst[pixel * 4 + 2] = raw[srcOff + 0]; // B + dst[pixel * 4 + 3] = hasAlpha ? raw[srcOff + 3] : 255; + srcOff += channels; + } + } + } + } + + // Converge on TOP-origin memory (matches NWN/D3D MDL UV convention used + // by reference renderers like borealis and dunahan/nwn_mdl_webviewer). + // Default TGAs are bottom-origin so flip them; explicitly top-origin TGAs + // need no flip. + if (!topOrigin) + flipRowsRGBA(dst, width, height); + + return uploadRGBA(gl, dst, width, height, hasAlpha); +} + +// Bioware DDS is NWN's proprietary texture format (not Microsoft DirectDraw +// Surface). It has no magic bytes and a 20-byte header: width, height, +// channelCount, pitchOrLinearSize, alphaPremultiplier (all little-endian). +// Pixel data is BCn-compressed: BC1 when channelCount==3, BC3 when ==4. +// Decoded pixels are uploaded top-origin to match NWN's D3D-convention UV +// coordinates (V=0 at the top of the texture); see also loadTGA, which +// converges on the same orientation. +// +// Spec: https://nwn.wiki/spaces/NWN1/pages/3473496/DDS +bool GpuTexture::loadBioDDS(QOpenGLFunctions_3_3_Core *gl, const QString &path) +{ + QByteArray raw; + if (!readTextureFile(path, raw)) + return false; + + constexpr int kHeaderSize = 20; + if (raw.size() < kHeaderSize) { + qWarning() << "GpuTexture: Bioware DDS too small for header" << path; + return false; + } + + const auto *bytes = reinterpret_cast(raw.constData()); + + // Microsoft DDS starts with "DDS " -- bail silently so the QImage fallback + // can pick it up if the qt6-image-formats DDS plugin is available. + if (bytes[0] == 'D' && bytes[1] == 'D' && bytes[2] == 'S' && bytes[3] == ' ') + return false; + + int width = static_cast(readU32LE(bytes + 0)); + int height = static_cast(readU32LE(bytes + 4)); + int channelCount = static_cast(readU32LE(bytes + 8)); + int pitch = static_cast(readU32LE(bytes + 12)); + // bytes + 16: alphaPremultiplier float, currently unused. + + if (width <= 0 || height <= 0 || + width > kMaxTextureDim || height > kMaxTextureDim || + (width % 4) != 0 || (height % 4) != 0) { + qWarning() << "GpuTexture: Bioware DDS dimensions invalid" + << width << "x" << height << "in" << path; + return false; + } + if (channelCount != 3 && channelCount != 4) { + qWarning() << "GpuTexture: Bioware DDS unsupported channelCount" + << channelCount << "in" << path; + return false; + } + + int blockBytes = (channelCount == 3) ? 8 : 16; + int blocksW = width / 4; + int blocksH = height / 4; + int expectedMain = blocksW * blocksH * blockBytes; + + // Per the spec, pitchOrLinearSize == main-mip byte length. We treat a + // mismatch as a soft warning rather than a hard reject -- the only thing + // we actually need is enough bytes to decode the main mip. + if (pitch != expectedMain) { + qWarning() << "GpuTexture: Bioware DDS pitch" << pitch + << "!= expected" << expectedMain << "for" + << width << "x" << height << "ch" << channelCount << path; + } + if (raw.size() < kHeaderSize + expectedMain) { + qWarning() << "GpuTexture: Bioware DDS pixel data truncated" << path; + return false; + } + + const unsigned char *blocks = bytes + kHeaderSize; + + QByteArray rgba(static_cast(width) * height * 4, '\0'); + auto *dst = reinterpret_cast(rgba.data()); + + if (channelCount == 3) { + for (int by = 0; by < blocksH; ++by) { + for (int bx = 0; bx < blocksW; ++bx) { + const unsigned char *src = blocks + (by * blocksW + bx) * 8; + decodeBC1Block(src, dst, width, height, bx * 4, by * 4); + } + } + } else { + for (int by = 0; by < blocksH; ++by) { + for (int bx = 0; bx < blocksW; ++bx) { + const unsigned char *src = blocks + (by * blocksW + bx) * 16; + decodeBC3Block(src, dst, width, height, bx * 4, by * 4); + } + } + } + + return uploadRGBA(gl, dst, width, height, channelCount == 4); +} + +bool GpuTexture::uploadRGBA(QOpenGLFunctions_3_3_Core *gl, + const unsigned char *data, int w, int h, bool hasAlpha) +{ + gl->glGenTextures(1, &m_texture); + gl->glBindTexture(GL_TEXTURE_2D, m_texture); + gl->glTexImage2D(GL_TEXTURE_2D, 0, hasAlpha ? GL_RGBA8 : GL_RGB8, + w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, data); + gl->glGenerateMipmap(GL_TEXTURE_2D); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); + gl->glBindTexture(GL_TEXTURE_2D, 0); + return true; +} + +void GpuTexture::bind(QOpenGLFunctions_3_3_Core *gl, int unit) const +{ + gl->glActiveTexture(GL_TEXTURE0 + unit); + gl->glBindTexture(GL_TEXTURE_2D, m_texture); +} + +void GpuTexture::destroy(QOpenGLFunctions_3_3_Core *gl) +{ + if (m_texture) { + gl->glDeleteTextures(1, &m_texture); + m_texture = 0; + } +} diff --git a/gputexture.h b/gputexture.h new file mode 100644 index 0000000..9aa9522 --- /dev/null +++ b/gputexture.h @@ -0,0 +1,28 @@ +#ifndef GPUTEXTURE_H +#define GPUTEXTURE_H + +#include +#include + +class GpuTexture +{ +public: + GpuTexture() = default; + ~GpuTexture() = default; + + bool loadFromFile(QOpenGLFunctions_3_3_Core *gl, const QString &path); + void bind(QOpenGLFunctions_3_3_Core *gl, int unit = 0) const; + void destroy(QOpenGLFunctions_3_3_Core *gl); + + bool isValid() const { return m_texture != 0; } + +private: + GLuint m_texture = 0; + + bool loadTGA(QOpenGLFunctions_3_3_Core *gl, const QString &path); + bool loadBioDDS(QOpenGLFunctions_3_3_Core *gl, const QString &path); + bool uploadRGBA(QOpenGLFunctions_3_3_Core *gl, const unsigned char *data, + int w, int h, bool hasAlpha); +}; + +#endif diff --git a/icons.qrc b/icons.qrc index ab8e695..ba3e391 100644 --- a/icons.qrc +++ b/icons.qrc @@ -1,22 +1,5 @@ - icons/reading-mdl.png - icons/cleaning-mdl.png - icons/decompile-button.png - icons/clean-success.png - icons/clean-error.png - icons/clean-button.png - icons/abort-button.png - icons/decompile-button.png - icons/load-preset.png - icons/save-preset.png - icons/whats-this.png - icons/mdl-ascii.png - icons/mdl-binary.png - icons/outdir.png - icons/indir.png - icons/lock-rescale.png - icons/unlock-rescale.png diff --git a/icons/lock-rescale.png b/icons/lock-rescale.png deleted file mode 100644 index d1731e9..0000000 Binary files a/icons/lock-rescale.png and /dev/null differ diff --git a/icons/unlock-rescale.png b/icons/unlock-rescale.png deleted file mode 100644 index 3222b37..0000000 Binary files a/icons/unlock-rescale.png and /dev/null differ diff --git a/loghtml.h b/loghtml.h new file mode 100644 index 0000000..2d3668c --- /dev/null +++ b/loghtml.h @@ -0,0 +1,61 @@ +#ifndef LOGHTML_H +#define LOGHTML_H + +#include "constants.h" + +#include +#include +#include + +// Compact helpers for assembling the small HTML fragments used by the raw log +// drawer and the detail panel. Every fragment in the codebase used to inline +// the same `""` template, which +// drifted in subtle ways (single vs double quotes, `
` placement, missing +// `
`). Centralising the templates here means one place defines the +// shape and every call site reads as the intent: "info line", "detail entry", +// not "string concatenation glyph soup". +// +// These helpers do NOT escape their input. Callers must pass `.toHtmlEscaped()` +// on any text that originated from a file path, CLI message, or user input. +// Static literals are safe to pass through as-is. +namespace LogHtml { + +// Inline coloured . For tagging a fragment within a larger sentence. +inline QString span(const char *color, const QString &innerHtml) +{ + return QStringLiteral("") % innerHtml % QStringLiteral(""); +} + +// Coloured

with a trailing
โ€” the canonical "one log line" shape used +// by `appendDebugHtml`. +inline QString logLine(const char *color, const QString &innerHtml) +{ + return QStringLiteral("

") % span(color, innerHtml) % QStringLiteral("


"); +} + +// Coloured

without the trailing break โ€” used by the detail panel where +// paragraph margins do the spacing instead of a literal
. +inline QString detailLine(const char *color, const QString &innerHtml) +{ + return QStringLiteral("

") % innerHtml % QStringLiteral("

"); +} + +// Coloured
  • for detail-panel lists (repairs, fix descriptions). +inline QString listItem(const char *color, const QString &innerHtml) +{ + return QStringLiteral("
  • ") % innerHtml % QStringLiteral("
  • "); +} + +// Coloured
     for verbatim stderr / stack-trace blocks.
    +inline QString preBlock(const char *color, const QString &innerHtml)
    +{
    +    return QStringLiteral("
    ") % innerHtml % QStringLiteral("
    "); +} + +} // namespace LogHtml + +#endif // LOGHTML_H diff --git a/main.cpp b/main.cpp index c5c3c1b..66b8a18 100644 --- a/main.cpp +++ b/main.cpp @@ -1,7 +1,17 @@ +#include "constants.h" #include "mainwindow.h" #include +#include + int main(int argc, char *argv[]) { + QSurfaceFormat fmt; + fmt.setVersion(GLDefaults::MajorVersion, GLDefaults::MinorVersion); + fmt.setProfile(QSurfaceFormat::CoreProfile); + fmt.setDepthBufferSize(GLDefaults::DepthBits); + fmt.setSamples(GLDefaults::Samples); + QSurfaceFormat::setDefaultFormat(fmt); + QApplication a(argc, argv); QCoreApplication::setOrganizationName("Clean Models Community"); QCoreApplication::setApplicationName("Clean Models::EE QT"); diff --git a/mainwindow.cpp b/mainwindow.cpp index ffb0ef3..185b02b 100644 --- a/mainwindow.cpp +++ b/mainwindow.cpp @@ -1,39 +1,980 @@ -๏ปฟ#include "fsmodel.h" +๏ปฟ#include "constants.h" +#include "fsmodel.h" +#include "loghtml.h" #include "mainwindow.h" +#include "metrics.h" +#include "modelviewport.h" #include "ui_mainwindow.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 -using namespace std; +// --------------------------------------------------------------------------- +// Collapsible section: flat button with disclosure triangle + content widget +// --------------------------------------------------------------------------- +QWidget *MainWindow::createCollapsibleGroup(const QString &title, QWidget **contentOut, bool startCollapsed) +{ + auto *container = new QWidget; + auto *vbox = new QVBoxLayout(container); + vbox->setContentsMargins(0, 0, 0, 0); + vbox->setSpacing(0); + + auto *header = new QPushButton(container); + header->setFlat(true); + header->setStyleSheet("QPushButton { text-align: left; padding: 4px 0; font-weight: bold; }" + "QPushButton:flat { border: none; }"); + + auto *content = new QWidget(container); + content->setVisible(!startCollapsed); + + auto updateArrow = [header, title](bool expanded) { + header->setText(QString(expanded ? "\u25BC " : "\u25B6 ") + title); + }; + updateArrow(!startCollapsed); + + connect(header, &QPushButton::clicked, content, [content, header, title, updateArrow] { + bool willExpand = !content->isVisible(); + content->setVisible(willExpand); + updateArrow(willExpand); + }); + + vbox->addWidget(header); + vbox->addWidget(content); + + *contentOut = content; + return container; +} + +// --------------------------------------------------------------------------- +// Build the entire UI programmatically +// --------------------------------------------------------------------------- +static void fillCombo(QComboBox *combo, const ComboOption *opts, int count) +{ + for (int i = 0; i < count; ++i) + combo->addItem(opts[i].label, QString(opts[i].cliValue)); + combo->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); +} + +template +static void fillCombo(QComboBox *combo, const ComboOption (&opts)[N]) +{ + fillCombo(combo, opts, static_cast(N)); +} + +static QLabel *boldLabel(const QString &text) +{ + auto *lbl = new QLabel(text); + QFont f = lbl->font(); + f.setBold(true); + lbl->setFont(f); + return lbl; +} + +static QLabel *smallLabel(const QString &text) +{ + auto *lbl = new QLabel(text); + QPalette p = lbl->palette(); + p.setColor(QPalette::WindowText, p.color(QPalette::WindowText).darker(130)); + lbl->setPalette(p); + return lbl; +} + +QFormLayout *MainWindow::makeStandardForm(int spacing) +{ + auto *form = new QFormLayout; + form->setSpacing(spacing); + form->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow); + form->setRowWrapPolicy(QFormLayout::WrapLongRows); + form->setLabelAlignment(Qt::AlignLeft); + return form; +} + +void MainWindow::updateModeUI() +{ + bool cleanMode = m_radioClean->isChecked(); + if (m_radioDecompile->isChecked()) { + m_cleanButton->setText("Decompile"); + m_cleanButton->setIcon(m_iconDecompileButton); + } else if (m_radioCompile->isChecked()) { + m_cleanButton->setText("Compile"); + m_cleanButton->setIcon(m_iconCleanButton); + } else { + m_cleanButton->setText("Clean"); + m_cleanButton->setIcon(m_iconCleanButton); + } + m_allFixesCheck->setEnabled(cleanMode); + m_fixesDetailWidget->setEnabled(cleanMode); + m_advancedGroup->setEnabled(cleanMode); + m_tileGroup->setEnabled(cleanMode); + m_pivotGroup->setEnabled(cleanMode); +} + +static QFrame *makeHLine() +{ + auto *f = new QFrame; + f->setFrameShape(QFrame::HLine); + f->setFrameShadow(QFrame::Sunken); + return f; +} + +void MainWindow::buildUi() +{ + auto *central = ui->centralWidget; + auto *root = new QVBoxLayout(central); + root->setContentsMargins(Layout::RootMargin, Layout::RootMargin, Layout::RootMargin, Layout::RootMargin); + root->setSpacing(0); + + // ==================================================================== + // LEFT SIDEBAR โ€” options panel + // ==================================================================== + m_sidebarWidget = new QWidget; + m_sidebarWidget->setMinimumWidth(Layout::SidebarMinWidth); + m_sidebarWidget->setMaximumWidth(Layout::SidebarMaxWidth); + auto *sidebarLayout = new QVBoxLayout(m_sidebarWidget); + sidebarLayout->setContentsMargins(Layout::RootMargin, Layout::SidebarTopPad, Layout::RootMargin, Layout::RootMargin); + sidebarLayout->setSpacing(0); + + // โ”€โ”€ I/O paths โ€” all 4 rows in one QFormLayout for uniform spacing โ”€ + auto *ioForm = makeStandardForm(Layout::DefaultSpacing); + ioForm->setContentsMargins(0, 0, 0, 0); + + m_indirButton = new QPushButton(style()->standardIcon(QStyle::SP_DirOpenIcon), ""); + m_indirButton->setAccessibleName(tr("Browse for input directory")); + m_indirButton->setToolTip(tr("Browse for input directory")); + m_outdirButton = new QPushButton(style()->standardIcon(QStyle::SP_DirOpenIcon), ""); + m_outdirButton->setAccessibleName(tr("Browse for output directory")); + m_outdirButton->setToolTip(tr("Browse for output directory")); + m_inDirectory = new QLineEdit; + m_inDirectory->setAccessibleName(tr("Input directory")); + m_inDirectory->setToolTip("Directory containing MDL files to process"); + m_outDirectory = new QLineEdit; + m_outDirectory->setAccessibleName(tr("Output directory")); + m_outDirectory->setToolTip("Output directory for processed files (leave empty to overwrite originals)"); + m_filePattern = new QLineEdit("*.mdl"); + m_filePattern->setAccessibleName(tr("File pattern")); + m_filePattern->setToolTip("Glob pattern to filter files (e.g. *.mdl)"); + m_classificationCombo = new QComboBox; + m_classificationCombo->setAccessibleName(tr("Model classification")); + fillCombo(m_classificationCombo, Options::Classification); + m_classificationCombo->setToolTip("Override model classification (Automatic detects from file)"); + + m_inDirectory->setPlaceholderText("Input directory..."); + m_outDirectory->setPlaceholderText("Output (optional)"); + + auto *inRow = new QHBoxLayout; + inRow->setSpacing(Layout::CompactSpacing); + inRow->addWidget(m_inDirectory, 1); + inRow->addWidget(m_indirButton); + ioForm->addRow(boldLabel("In:"), inRow); + + auto *outRow = new QHBoxLayout; + outRow->setSpacing(Layout::CompactSpacing); + outRow->addWidget(m_outDirectory, 1); + outRow->addWidget(m_outdirButton); + ioForm->addRow(boldLabel("Out:"), outRow); + + ioForm->addRow(boldLabel("Pattern:"), m_filePattern); + ioForm->addRow(boldLabel("Class:"), m_classificationCombo); + + sidebarLayout->addLayout(ioForm); + sidebarLayout->addSpacing(Layout::SectionGap); + + // โ”€โ”€ Scroll area for options โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + auto *scroll = new QScrollArea; + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + auto *optionsWidget = new QWidget; + auto *optionsLayout = new QVBoxLayout(optionsWidget); + // Right padding prevents content clipping when vertical scrollbar appears + optionsLayout->setContentsMargins(0, 0, Layout::SectionGap, 0); + optionsLayout->setSpacing(Layout::DefaultSpacing); + scroll->setWidget(optionsWidget); + + // โ”€โ”€ Mode โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + auto *modeRow = new QHBoxLayout; + m_radioClean = new QRadioButton("Clean"); + m_radioClean->setToolTip("Parse, validate, repair, and recompile models"); + m_radioDecompile = new QRadioButton("Decompile"); + m_radioDecompile->setToolTip("Decompile binary MDL to ASCII without cleaning"); + m_radioCompile = new QRadioButton("Compile"); + m_radioCompile->setToolTip("Compile ASCII MDL to binary format"); + m_radioClean->setChecked(true); + modeRow->addWidget(boldLabel("Mode:")); + modeRow->addWidget(m_radioClean); + modeRow->addWidget(m_radioDecompile); + modeRow->addWidget(m_radioCompile); + modeRow->addStretch(); + optionsLayout->addLayout(modeRow); + + // โ”€โ”€ All Fixes master checkbox โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + m_allFixesCheck = new QCheckBox("All Fixes (recommended)"); + m_allFixesCheck->setToolTip("Enable all recommended fixes with default settings"); + m_allFixesCheck->setChecked(true); + optionsLayout->addWidget(m_allFixesCheck); + + m_fixesDetailWidget = new QWidget; + auto *fixesGrid = new QVBoxLayout(m_fixesDetailWidget); + fixesGrid->setContentsMargins(Layout::IndentLeft, 0, 0, 0); + fixesGrid->setSpacing(Layout::DefaultSpacing); + + m_checkValidateAll = new QCheckBox("Run validation checks"); + m_checkValidateAll->setToolTip("Validate model structure, geometry, parameters, animations, and emitters.\n" + "Auto-fixes safe issues like duplicate names and orphaned nodes."); + m_checkValidateAll->setChecked(true); + fixesGrid->addWidget(m_checkValidateAll); + + m_checksDetailWidget = new QWidget; + auto *checksDetailLayout = new QVBoxLayout(m_checksDetailWidget); + checksDetailLayout->setContentsMargins(Layout::IndentLeft, 0, 0, 0); + checksDetailLayout->setSpacing(Layout::CompactSpacing); + m_checksDetailWidget->setVisible(false); + fixesGrid->addWidget(m_checksDetailWidget); + + connect(m_checkValidateAll, &QCheckBox::toggled, this, [this](bool allOn) { + m_checksDetailWidget->setVisible(!allOn); + }); + + m_checkStripDegen = new QCheckBox("Strip degenerate faces"); + m_checkStripDegen->setToolTip("Remove triangles with zero area (collapsed vertices)"); + m_checkFixAnims = new QCheckBox("Fix animations"); + m_checkFixAnims->setToolTip("Fix animation lengths and missing end-keys"); + m_checkRepairPivots = new QCheckBox("Repair pivots"); + m_checkRepairPivots->setToolTip("Repair walkmesh pivot points for proper tile pathfinding"); + m_checkFixTilefade = new QCheckBox("Fix tilefade (TILE only)"); + m_checkFixTilefade->setToolTip("Slice geometry at fade height for tile transparency"); + m_checkTilefadeUndo = new QCheckBox("Undo tilefade splits"); + m_checkTilefadeUndo->setToolTip("Remove existing tilefade splits from tile geometry"); + m_checkTilefadeUndo->setChecked(false); + m_checkRebuildAABB = new QCheckBox("Rebuild AABB"); + m_checkRebuildAABB->setToolTip("Rebuild axis-aligned bounding box tree for walkmesh"); + m_checkReparentChildren = new QCheckBox("Reparent children"); + m_checkReparentChildren->setToolTip("Move child nodes off restricted parent types (AABB, light)"); + m_checkWrapRoot = new QCheckBox("Wrap root in dummy"); + m_checkWrapRoot->setToolTip("Wrap root node in a dummy if it has geometry"); + m_checkSplitMultiEdge = new QCheckBox("Split multi-edge shadows"); + m_checkSplitMultiEdge->setToolTip("Fix non-manifold edges that break shadow rendering"); + + for (auto *cb : {m_checkStripDegen, m_checkFixAnims, + m_checkRepairPivots, m_checkFixTilefade, m_checkRebuildAABB, + m_checkReparentChildren, m_checkWrapRoot, m_checkSplitMultiEdge}) + { + cb->setChecked(true); + fixesGrid->addWidget(cb); + } + fixesGrid->addWidget(m_checkTilefadeUndo); + + m_fixesDetailWidget->setVisible(false); + optionsLayout->addWidget(m_fixesDetailWidget); + + connect(m_allFixesCheck, &QCheckBox::toggled, this, [this](bool allOn) { + m_fixesDetailWidget->setVisible(!allOn); + if (allOn) + { + m_checkValidateAll->setChecked(true); + for (auto *cb : {m_checkStripDegen, m_checkFixAnims, + m_checkRepairPivots, m_checkFixTilefade, m_checkRebuildAABB, + m_checkReparentChildren, m_checkWrapRoot, m_checkSplitMultiEdge}) + cb->setChecked(true); + } + }); + + // โ”€โ”€ Advanced Options (collapsible) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + QWidget *advContent; + m_advancedGroup = createCollapsibleGroup("Advanced Options", &advContent, true); + auto *advLayout = new QVBoxLayout; + advLayout->setContentsMargins(Layout::GroupMarginH, Layout::GroupMarginTop, Layout::GroupMarginH, Layout::GroupMarginBottom); + advLayout->setSpacing(Layout::DefaultSpacing); + + m_scaleXSpin = new QDoubleSpinBox; m_scaleXSpin->setRange(0.01, 100); m_scaleXSpin->setValue(1.0); m_scaleXSpin->setSingleStep(0.1); m_scaleXSpin->setDecimals(2); + m_scaleXSpin->setToolTip("X-axis scale factor"); + m_scaleYSpin = new QDoubleSpinBox; m_scaleYSpin->setRange(0.01, 100); m_scaleYSpin->setValue(1.0); m_scaleYSpin->setSingleStep(0.1); m_scaleYSpin->setDecimals(2); + m_scaleYSpin->setToolTip("Y-axis scale factor"); + m_scaleZSpin = new QDoubleSpinBox; m_scaleZSpin->setRange(0.01, 100); m_scaleZSpin->setValue(1.0); m_scaleZSpin->setSingleStep(0.1); m_scaleZSpin->setDecimals(2); + m_scaleZSpin->setToolTip("Z-axis scale factor"); + m_scaleLockBtn = new QPushButton(QString::fromUtf8("๐Ÿ”’")); + m_scaleLockBtn->setCheckable(true); + m_scaleLockBtn->setChecked(true); + m_scaleLockBtn->setFlat(true); + m_scaleLockBtn->setAccessibleName(tr("Lock uniform scaling")); + m_scaleLockBtn->setToolTip(tr("Lock/unlock uniform scaling")); + m_scaleYSpin->setEnabled(false); + m_scaleZSpin->setEnabled(false); + + auto *scaleGrid = new QGridLayout; + scaleGrid->setSpacing(Layout::CompactSpacing); + scaleGrid->addWidget(new QLabel("Scale X:"), 0, 0); + scaleGrid->addWidget(m_scaleXSpin, 0, 1); + scaleGrid->addWidget(m_scaleLockBtn, 0, 2); + scaleGrid->addWidget(new QLabel("Y:"), 1, 0); + scaleGrid->addWidget(m_scaleYSpin, 1, 1); + scaleGrid->addWidget(new QLabel("Z:"), 2, 0); + scaleGrid->addWidget(m_scaleZSpin, 2, 1); + scaleGrid->setColumnStretch(1, 1); + advLayout->addLayout(scaleGrid); + + advLayout->addWidget(makeHLine()); + + connect(m_scaleLockBtn, &QPushButton::toggled, this, [this](bool locked) { + m_scaleLockBtn->setText(locked ? QString::fromUtf8("๐Ÿ”’") : QString::fromUtf8("๐Ÿ”“")); + m_scaleYSpin->setEnabled(!locked); + m_scaleZSpin->setEnabled(!locked); + if (locked) { m_scaleYSpin->setValue(m_scaleXSpin->value()); m_scaleZSpin->setValue(m_scaleXSpin->value()); } + }); + connect(m_scaleXSpin, QOverload::of(&QDoubleSpinBox::valueChanged), this, [this](double v) { + if (m_scaleLockBtn->isChecked()) { + QSignalBlocker b1(m_scaleYSpin), b2(m_scaleZSpin); + m_scaleYSpin->setValue(v); m_scaleZSpin->setValue(v); + } + }); + + auto *meshForm = makeStandardForm(); + m_snapCombo = new QComboBox; fillCombo(m_snapCombo, Options::Snap); + m_snapCombo->setToolTip("Snap vertex positions to a grid (reduces file size)"); + m_tvertSnapCombo = new QComboBox; fillCombo(m_tvertSnapCombo, Options::TvertSnap); + m_tvertSnapCombo->setToolTip("Snap texture coordinates to a grid resolution"); + m_renderCombo = new QComboBox; fillCombo(m_renderCombo, Options::RenderOverride); + m_renderCombo->setToolTip("Override render flag on all mesh nodes"); + m_shadowCombo = new QComboBox; fillCombo(m_shadowCombo, Options::ShadowOverride); + m_shadowCombo->setToolTip("Override shadow flag on all mesh nodes"); + meshForm->addRow("Snap:", m_snapCombo); + meshForm->addRow("TVert Snap:", m_tvertSnapCombo); + meshForm->addRow("Render:", m_renderCombo); + meshForm->addRow("Shadow:", m_shadowCombo); + advLayout->addLayout(meshForm); + + advLayout->addWidget(makeHLine()); + + m_forceWhiteCheck = new QCheckBox("Force white ambient/diffuse"); + m_forceWhiteCheck->setToolTip("Set ambient and diffuse colors to white on all meshes"); + m_mergeByBitmapCheck = new QCheckBox("Merge meshes by bitmap"); + m_mergeByBitmapCheck->setToolTip("Merge mesh nodes that share the same texture"); + m_cullInvisibleCheck = new QCheckBox("Cull invisible meshes"); + m_cullInvisibleCheck->setToolTip("Remove mesh nodes with render=0 and no animations"); + m_standardizeTexture0Check = new QCheckBox("Standardize bitmap โ†’ texture0"); + m_standardizeTexture0Check->setToolTip("Emit texture0 instead of bitmap in output (EE standard)"); + m_stripEEExtrasCheck = new QCheckBox("Strip unused EE fields"); + m_stripEEExtrasCheck->setToolTip("Remove wirecolor, specular, and shininess from output"); + m_placeableTransCheck = new QCheckBox("Placeable with transparency"); + m_placeableTransCheck->setToolTip("Set transparency hint on meshes matching the key"); + m_transparencyKeyEdit = new QLineEdit("glass"); + m_transparencyKeyEdit->setToolTip("Bitmap name substring to match for transparency"); + m_transparencyKeyEdit->setPlaceholderText("bitmap key"); + + auto *meshOpsGroup = new QVBoxLayout; + meshOpsGroup->setSpacing(Layout::RootMargin); + meshOpsGroup->addWidget(m_forceWhiteCheck); + meshOpsGroup->addWidget(m_mergeByBitmapCheck); + meshOpsGroup->addWidget(m_cullInvisibleCheck); + meshOpsGroup->addWidget(m_standardizeTexture0Check); + meshOpsGroup->addWidget(m_stripEEExtrasCheck); + meshOpsGroup->addWidget(m_placeableTransCheck); + auto *transKeyWidget = new QWidget; + auto *transKeyRow = new QHBoxLayout(transKeyWidget); + transKeyRow->setContentsMargins(Layout::IndentLeft, 0, 0, 0); + transKeyRow->addWidget(new QLabel("Key:")); + transKeyRow->addWidget(m_transparencyKeyEdit); + transKeyWidget->setVisible(m_placeableTransCheck->isChecked()); + connect(m_placeableTransCheck, &QCheckBox::toggled, transKeyWidget, &QWidget::setVisible); + meshOpsGroup->addWidget(transKeyWidget); + advLayout->addLayout(meshOpsGroup); + + advContent->setLayout(advLayout); + optionsLayout->addWidget(m_advancedGroup); + + // โ”€โ”€ Tile Options (collapsible) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + QWidget *tileContent; + m_tileGroup = createCollapsibleGroup("Tile Options", &tileContent, true); + auto *tileLayout = new QVBoxLayout; + tileLayout->setContentsMargins(Layout::GroupMarginH, Layout::GroupMarginTop, Layout::GroupMarginH, Layout::GroupMarginBottom); + tileLayout->setSpacing(Layout::RootMargin); + + auto *tileForm = makeStandardForm(); + + m_sliceHeightSpin = new QDoubleSpinBox; m_sliceHeightSpin->setRange(1, 100); m_sliceHeightSpin->setValue(20.0); m_sliceHeightSpin->setDecimals(1); + m_sliceHeightSpin->setToolTip("Height at which to slice geometry for tilefade (in 10cm units)"); + tileForm->addRow("Slice height:", m_sliceHeightSpin); + + m_foliageCombo = new QComboBox; fillCombo(m_foliageCombo, Options::Foliage); + m_foliageCombo->setToolTip("How to handle foliage meshes in tiles"); + m_foliageKeyEdit = new QLineEdit("trefol"); + m_foliageKeyEdit->setToolTip("Bitmap name substring identifying foliage meshes"); + tileForm->addRow("Foliage:", m_foliageCombo); + tileForm->addRow("Foliage key:", m_foliageKeyEdit); + + m_rotateGroundCombo = new QComboBox; fillCombo(m_rotateGroundCombo, Options::RotateToggle); + m_rotateGroundCombo->setToolTip("Set rotatetexture flag on ground meshes"); + m_groundKeyEdit = new QLineEdit; + m_groundKeyEdit->setToolTip("Bitmap name substring identifying ground meshes"); + tileForm->addRow("Ground rotate:", m_rotateGroundCombo); + tileForm->addRow("Ground key:", m_groundKeyEdit); + + m_chamferCombo = new QComboBox; fillCombo(m_chamferCombo, Options::Chamfer); + m_chamferCombo->setToolTip("Add or remove chamfer geometry on tile edges"); + m_retileGroundCombo = new QComboBox; fillCombo(m_retileGroundCombo, Options::RetileSize); + m_retileGroundCombo->setToolTip("Retile ground textures to a different grid size"); + tileForm->addRow("Chamfers:", m_chamferCombo); + tileForm->addRow("Retile ground:", m_retileGroundCombo); + + m_raiseLowerCombo = new QComboBox; fillCombo(m_raiseLowerCombo, Options::RaiseLower); + m_raiseLowerCombo->setToolTip("Raise or lower all geometry by a fixed amount"); + m_raiseAmountSpin = new QDoubleSpinBox; m_raiseAmountSpin->setRange(0.01, 10); m_raiseAmountSpin->setValue(1.0); m_raiseAmountSpin->setDecimals(2); m_raiseAmountSpin->setEnabled(false); + m_raiseAmountSpin->setToolTip("Amount to raise/lower in meters"); + tileForm->addRow("Raise/Lower:", m_raiseLowerCombo); + tileForm->addRow("Amount (m):", m_raiseAmountSpin); + + tileLayout->addLayout(tileForm); + + connect(m_raiseLowerCombo, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int i) { + m_raiseAmountSpin->setEnabled(i > 0); + }); + + tileLayout->addWidget(makeHLine()); + + m_waterEnableCheck = new QCheckBox("Water fixups"); + m_waterEnableCheck->setToolTip("Enable water mesh processing for tiles"); + tileLayout->addWidget(m_waterEnableCheck); + + auto *waterWidget = new QWidget; + auto *waterForm = makeStandardForm(); + waterWidget->setLayout(waterForm); + waterForm->setContentsMargins(Layout::IndentLeft, 0, 0, 0); + m_waterKeyEdit = new QLineEdit("water"); + m_waterKeyEdit->setToolTip("Bitmap name substring identifying water meshes"); + m_dynamicWaterCombo = new QComboBox; fillCombo(m_dynamicWaterCombo, Options::DynamicWater); + m_dynamicWaterCombo->setToolTip("Water animation mode (wavy adds wave displacement)"); + m_waveHeightSpin = new QDoubleSpinBox; m_waveHeightSpin->setRange(0.1, 10); m_waveHeightSpin->setValue(0.1); m_waveHeightSpin->setEnabled(false); + m_waveHeightSpin->setToolTip("Wave displacement height for wavy water"); + m_rotateWaterCombo = new QComboBox; fillCombo(m_rotateWaterCombo, Options::RotateToggle); + m_rotateWaterCombo->setToolTip("Set rotatetexture flag on water meshes"); + m_retileWaterCombo = new QComboBox; fillCombo(m_retileWaterCombo, Options::RetileSize); + m_retileWaterCombo->setToolTip("Retile water textures to a different grid size"); + + waterForm->addRow("Key:", m_waterKeyEdit); + waterForm->addRow("Dynamic:", m_dynamicWaterCombo); + waterForm->addRow("Wave height:", m_waveHeightSpin); + waterForm->addRow("Rotate:", m_rotateWaterCombo); + waterForm->addRow("Retile:", m_retileWaterCombo); + + waterWidget->setVisible(false); + tileLayout->addWidget(waterWidget); + connect(m_waterEnableCheck, &QCheckBox::toggled, waterWidget, &QWidget::setVisible); + connect(m_dynamicWaterCombo, QOverload::of(&QComboBox::currentIndexChanged), this, [this] { + bool isWavy = m_dynamicWaterCombo->currentData().toString() == "wavy"; + m_waveHeightSpin->setEnabled(isWavy); + m_retileWaterCombo->setEnabled(!isWavy); + }); + + m_animateSplotchesCheck = new QCheckBox("Animate splotches"); + m_animateSplotchesCheck->setToolTip("Add animation to splotch/decal meshes"); + m_splotchKeyEdit = new QLineEdit; + m_splotchKeyEdit->setToolTip("Bitmap name substring identifying splotch meshes"); + m_splotchKeyEdit->setPlaceholderText("bitmap key"); + tileLayout->addWidget(m_animateSplotchesCheck); + auto *splotchKeyRow = new QHBoxLayout; + splotchKeyRow->setContentsMargins(Layout::IndentLeft, 0, 0, 0); + splotchKeyRow->addWidget(new QLabel("Key:")); + splotchKeyRow->addWidget(m_splotchKeyEdit); + tileLayout->addLayout(splotchKeyRow); + + m_remapWokMatCheck = new QCheckBox("Remap walkmesh material"); + m_remapWokMatCheck->setToolTip("Remap walkmesh material IDs (e.g. change grass to dirt)"); + m_wokMatFromSpin = new QSpinBox; m_wokMatFromSpin->setRange(0, 30); + m_wokMatFromSpin->setToolTip("Source material ID to remap from"); + m_wokMatToSpin = new QSpinBox; m_wokMatToSpin->setRange(0, 30); + m_wokMatToSpin->setToolTip("Target material ID to remap to"); + tileLayout->addWidget(m_remapWokMatCheck); + auto *wokRow = new QHBoxLayout; + wokRow->setContentsMargins(Layout::IndentLeft, 0, 0, 0); + wokRow->addWidget(m_wokMatFromSpin); + wokRow->addWidget(new QLabel(QString::fromUtf8("\xe2\x86\x92"))); + wokRow->addWidget(m_wokMatToSpin); + wokRow->addStretch(); + tileLayout->addLayout(wokRow); + + tileContent->setLayout(tileLayout); + optionsLayout->addWidget(m_tileGroup); + + // โ”€โ”€ Pivot Options (collapsible) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + QWidget *pivotContent; + m_pivotGroup = createCollapsibleGroup("Pivot Options", &pivotContent, true); + auto *pivotLayout = new QVBoxLayout; + pivotLayout->setContentsMargins(Layout::GroupMarginH, Layout::GroupMarginTop, Layout::GroupMarginH, Layout::GroupMarginBottom); + pivotLayout->setSpacing(Layout::DefaultSpacing); + + m_pivotAllowSplitCheck = new QCheckBox("Allow splitting"); + m_pivotAllowSplitCheck->setToolTip("Allow splitting walkmesh faces to fix bad pivots"); + m_pivotBelowZ0Combo = new QComboBox; fillCombo(m_pivotBelowZ0Combo, Options::PivotBelowZ0); + m_pivotBelowZ0Combo->setToolTip("How to handle pivot points below ground level"); + m_pivotMoveBadCombo = new QComboBox; fillCombo(m_pivotMoveBadCombo, Options::PivotMoveBad); + m_pivotMoveBadCombo->setToolTip("Where to move pivots that fail validation"); + m_pivotSmoothingCombo = new QComboBox; fillCombo(m_pivotSmoothingCombo, Options::PivotSmoothing); + m_pivotSmoothingCombo->setToolTip("How smoothing groups affect pivot computation"); + m_pivotMinFacesSpin = new QSpinBox; m_pivotMinFacesSpin->setRange(2, 8); m_pivotMinFacesSpin->setValue(4); + m_pivotMinFacesSpin->setToolTip("Minimum number of faces per pivot region"); + m_pivotSplitFirstCombo = new QComboBox; fillCombo(m_pivotSplitFirstCombo, Options::PivotSplitFirst); + m_pivotSplitFirstCombo->setToolTip("Whether to split convex or concave regions first"); + + pivotLayout->addWidget(m_pivotAllowSplitCheck); + auto *pivotForm = makeStandardForm(); + pivotForm->addRow("Below Z=0:", m_pivotBelowZ0Combo); + pivotForm->addRow("Move bad:", m_pivotMoveBadCombo); + pivotForm->addRow("Smoothing:", m_pivotSmoothingCombo); + pivotForm->addRow("Min faces:", m_pivotMinFacesSpin); + pivotForm->addRow("Split first:", m_pivotSplitFirstCombo); + pivotLayout->addLayout(pivotForm); + + pivotContent->setLayout(pivotLayout); + optionsLayout->addWidget(m_pivotGroup); + + optionsLayout->addStretch(); + + sidebarLayout->addWidget(scroll, 1); + + // โ”€โ”€ Clean button โ€” pinned at bottom of sidebar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + sidebarLayout->addWidget(makeHLine()); + + m_cleanButton = new QPushButton("Clean"); + m_cleanButton->setIcon(style()->standardIcon(QStyle::SP_MediaPlay)); + m_cleanButton->setAccessibleName(tr("Run cleanmodels")); + m_cleanButton->setToolTip(tr("Run cleanmodels on all files (F5, โŒ˜โ†ต)")); + m_cleanButton->setEnabled(false); + QFont cleanFont = m_cleanButton->font(); + cleanFont.setBold(true); + m_cleanButton->setFont(cleanFont); + // Set min height after the bold font is applied so metrics reflect the + // actual line height of the rendered label, not the default UI font. + m_cleanButton->setMinimumHeight(Layout::emH(QFontMetrics(m_cleanButton->font()), + Layout::CleanButtonHeightEm)); + sidebarLayout->addWidget(m_cleanButton); + + m_sidebarToggleBtn = new QPushButton(tr("Hide Sidebar")); + m_sidebarToggleBtn->setFlat(true); + m_sidebarToggleBtn->setFixedHeight(Layout::emH(QFontMetrics(m_sidebarToggleBtn->font()), + Layout::SidebarToggleHeightEm)); + m_sidebarToggleBtn->setAccessibleName(tr("Toggle sidebar visibility")); + m_sidebarToggleBtn->setToolTip(tr("Show or hide the options sidebar (Ctrl+Shift+S)")); + sidebarLayout->addWidget(m_sidebarToggleBtn); + + // ==================================================================== + // RIGHT WORKSPACE โ€” table, detail panel, viewport, raw log drawer + // ==================================================================== + auto *workspaceWidget = new QWidget; + auto *workspaceLayout = new QVBoxLayout(workspaceWidget); + workspaceLayout->setContentsMargins(0, 0, 0, 0); + workspaceLayout->setSpacing(Layout::DefaultSpacing); + + // โ”€โ”€ Stats row โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + auto *statsRow = new QHBoxLayout; + m_mdlsDetectedLabel = smallLabel("Detected: 0"); + m_mdlsCleanedLabel = smallLabel("Cleaned: 0"); + m_mdlsFailedLabel = smallLabel("Failed: 0"); + statsRow->addWidget(m_mdlsDetectedLabel); + statsRow->addSpacing(Layout::SectionGap); + statsRow->addWidget(m_mdlsCleanedLabel); + statsRow->addSpacing(Layout::SectionGap); + statsRow->addWidget(m_mdlsFailedLabel); + statsRow->addStretch(); + workspaceLayout->addLayout(statsRow); + + // โ”€โ”€ File table โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + m_filesTable = new QTableWidget; + m_filesTable->setAccessibleName(tr("Files table")); + m_filesTable->setColumnCount(5); + m_filesTable->setHorizontalHeaderLabels({"File", "Size", "Status", "Fixes", "Time"}); + m_filesTable->setAlternatingRowColors(true); + m_filesTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Stretch); + { + const QFontMetrics fm(m_filesTable->font()); + m_filesTable->setColumnWidth(1, Layout::emW(fm, Layout::TableColumnSizeChars)); + m_filesTable->setColumnWidth(2, Layout::emW(fm, Layout::TableColumnStatusChars)); + m_filesTable->setColumnWidth(3, Layout::emW(fm, Layout::TableColumnFixesChars)); + m_filesTable->setColumnWidth(4, Layout::emW(fm, Layout::TableColumnTimeChars)); + m_filesTable->verticalHeader()->setDefaultSectionSize( + Layout::emH(fm, Layout::TableRowHeightEm)); + } + QFont headerFont = m_filesTable->horizontalHeader()->font(); + headerFont.setBold(true); + m_filesTable->horizontalHeader()->setFont(headerFont); + m_filesTable->horizontalHeader()->setVisible(true); + m_filesTable->verticalHeader()->setVisible(false); + m_filesTable->setContextMenuPolicy(Qt::CustomContextMenu); + m_filesTable->setSelectionMode(QAbstractItemView::ExtendedSelection); + m_filesTable->setSelectionBehavior(QAbstractItemView::SelectRows); + m_filesTable->setEditTriggers(QAbstractItemView::NoEditTriggers); + + // โ”€โ”€ Detail panel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + m_detailPanel = new QTextBrowser; + m_detailPanel->setAccessibleName(tr("File details")); + m_detailPanel->setReadOnly(true); + m_detailPanel->setHtml(QStringLiteral("

    Click a file to see details

    ")); + m_detailPanel->setMinimumHeight(Layout::emH(QFontMetrics(m_detailPanel->font()), + Layout::DetailPanelMinHeightEm)); + + // โ”€โ”€ Table + detail splitter โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + m_tableDetailSplitter = new QSplitter(Qt::Vertical); + m_tableDetailSplitter->addWidget(m_filesTable); + m_tableDetailSplitter->addWidget(m_detailPanel); + m_tableDetailSplitter->setStretchFactor(0, 3); + m_tableDetailSplitter->setStretchFactor(1, 1); + + // โ”€โ”€ Raw log drawer (uses existing m_debugTextBrowser) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + m_debugTextBrowser = new QTextBrowser; + m_debugTextBrowser->setAccessibleName(tr("Raw cleanmodels log")); + m_debugTextBrowser->setPlaceholderText("Output from cleanmodels will appear here.\nClick Clean to begin processing."); + QFont logFont = QFontDatabase::systemFont(QFontDatabase::FixedFont); + logFont.setPointSizeF(logFont.pointSizeF() * 0.9); + m_debugTextBrowser->setFont(logFont); + + m_rawLogDrawer = new QWidget; + auto *drawerLayout = new QVBoxLayout(m_rawLogDrawer); + drawerLayout->setContentsMargins(0, 0, 0, 0); + drawerLayout->setSpacing(0); + + auto *drawerTitleBar = new QWidget; + auto *drawerTitleLayout = new QHBoxLayout(drawerTitleBar); + drawerTitleLayout->setContentsMargins(Layout::DefaultSpacing, 0, + Layout::CompactSpacing, 0); + drawerTitleLayout->setSpacing(Layout::CompactSpacing); + auto *drawerLabel = new QLabel("Raw Log"); + QFont drawerFont = drawerLabel->font(); + drawerFont.setBold(true); + drawerFont.setPointSizeF(drawerFont.pointSizeF() * 0.85); + drawerLabel->setFont(drawerFont); + drawerTitleBar->setFixedHeight(Layout::emH(QFontMetrics(drawerFont), + Layout::DrawerTitleHeightEm)); + auto *drawerCloseBtn = new QPushButton(QString::fromUtf8("\xc3\x97")); + { + const int s = Layout::emH(QFontMetrics(drawerCloseBtn->font()), + Layout::DrawerCloseSizeEm); + drawerCloseBtn->setFixedSize(s, s); + } + drawerCloseBtn->setFlat(true); + drawerTitleLayout->addWidget(drawerLabel); + drawerTitleLayout->addStretch(); + drawerTitleLayout->addWidget(drawerCloseBtn); + + drawerLayout->addWidget(drawerTitleBar); + drawerLayout->addWidget(m_debugTextBrowser, 1); + + m_rawLogDrawer->setVisible(false); + m_rawLogVisible = false; + + connect(drawerCloseBtn, &QPushButton::clicked, this, &MainWindow::toggleRawLog); + + // โ”€โ”€ Viewport with toolbar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + m_viewport = new ModelViewport(this); + m_viewport->setAccessibleName(tr("3D model viewport")); + m_viewport->setAccessibleDescription(tr("Live preview of the selected MDL. Drag to orbit, right-drag to pan, scroll to zoom.")); + m_viewport->setMinimumSize(200, 150); + + auto *viewportContainer = new QWidget; + auto *viewportVLayout = new QVBoxLayout(viewportContainer); + viewportVLayout->setContentsMargins(0, 0, 0, 0); + viewportVLayout->setSpacing(Layout::CompactSpacing); + + auto *viewToolbar = new QHBoxLayout; + m_wireframeCheck = new QCheckBox("Wireframe"); + m_wireframeCheck->setAccessibleName(tr("Wireframe rendering")); + m_gridCheck = new QCheckBox("Grid"); + m_gridCheck->setAccessibleName(tr("Show ground grid")); + m_gridCheck->setChecked(true); + m_refModelCheck = new QCheckBox("Reference:"); + m_refModelCheck->setAccessibleName(tr("Show reference model overlay")); + m_refModelCombo = new QComboBox; + m_refModelCombo->setAccessibleName(tr("Reference model")); + m_refModelCombo->setMinimumWidth(Layout::emW(QFontMetrics(m_refModelCombo->font()), + Layout::RefModelComboWidthChars)); + m_refModelCombo->setEditable(false); + m_refModelCombo->addItem("(none)"); + m_refModelCombo->setEnabled(false); + m_refBrowseBtn = new QPushButton("Browseโ€ฆ"); + m_refBrowseBtn->setAccessibleName(tr("Browse for reference model file")); + m_refBrowseBtn->setEnabled(false); + + m_animLabel = new QLabel("Anim:"); + m_animCombo = new QComboBox; + m_animCombo->setAccessibleName(tr("Animation")); + m_animCombo->setMinimumWidth(Layout::emW(QFontMetrics(m_animCombo->font()), + Layout::RefModelComboWidthChars)); + m_animCombo->addItem("(bind pose)"); + m_animCombo->setEnabled(false); + + viewToolbar->setSpacing(Layout::RootMargin); + viewToolbar->addWidget(m_wireframeCheck); + viewToolbar->addWidget(m_gridCheck); + viewToolbar->addSpacing(Layout::SectionGap); + viewToolbar->addWidget(m_animLabel); + viewToolbar->addWidget(m_animCombo); + viewToolbar->addSpacing(Layout::SectionGap); + viewToolbar->addWidget(m_refModelCheck); + viewToolbar->addWidget(m_refModelCombo); + viewToolbar->addWidget(m_refBrowseBtn); + viewToolbar->addStretch(); + viewportVLayout->addLayout(viewToolbar); + viewportVLayout->addWidget(m_viewport, 1); + + // โ”€โ”€ Vertical splitter: table+detail (top) | viewport+drawer (bottom) + auto *workspaceSplitter = new QSplitter(Qt::Vertical); + workspaceSplitter->addWidget(m_tableDetailSplitter); + auto *viewportAndDrawer = new QWidget; + auto *viewportAndDrawerLayout = new QVBoxLayout(viewportAndDrawer); + viewportAndDrawerLayout->setContentsMargins(0, 0, 0, 0); + viewportAndDrawerLayout->setSpacing(0); + viewportAndDrawerLayout->addWidget(viewportContainer, 1); + viewportAndDrawerLayout->addWidget(m_rawLogDrawer); + workspaceSplitter->addWidget(viewportAndDrawer); + workspaceSplitter->setStretchFactor(0, 2); + workspaceSplitter->setStretchFactor(1, 3); + workspaceLayout->addWidget(workspaceSplitter, 1); + + // ==================================================================== + // MAIN SPLITTER โ€” sidebar | workspace + // ==================================================================== + m_mainSplitter = new QSplitter(Qt::Horizontal, central); + m_mainSplitter->addWidget(m_sidebarWidget); + m_mainSplitter->addWidget(workspaceWidget); + m_mainSplitter->setStretchFactor(0, 0); + m_mainSplitter->setStretchFactor(1, 1); + m_mainSplitter->setSizes({Layout::SidebarDefaultWidth, 700}); + + root->addWidget(m_mainSplitter, 1); + + // ==================================================================== + // SIGNALS โ€” identical to before + // ==================================================================== + connect(m_wireframeCheck, &QCheckBox::toggled, this, [this](bool on) { + m_viewport->setWireframe(on); + }); + connect(m_gridCheck, &QCheckBox::toggled, this, [this](bool on) { + m_viewport->setShowGrid(on); + }); + connect(m_refModelCheck, &QCheckBox::toggled, this, [this](bool on) { + m_refModelCombo->setEnabled(on); + m_refBrowseBtn->setEnabled(on); + if (!on) { + m_viewport->clearReference(); + } else if (m_refModelCombo->currentIndex() > 0) { + m_viewport->loadReferenceFile(m_refModelCombo->currentData().toString()); + } + }); + connect(m_refBrowseBtn, &QPushButton::clicked, this, [this] { + QString file = QFileDialog::getOpenFileName(this, "Reference Model", + m_sInDir.isEmpty() ? QDir::currentPath() : m_sInDir, "MDL Files (*.mdl)"); + if (file.isEmpty()) return; + QFileInfo fi(file); + QString label = fi.fileName(); + int idx = m_refModelCombo->findData(file); + if (idx < 0) { + m_refModelCombo->addItem(label, file); + idx = m_refModelCombo->count() - 1; + } + m_refModelCombo->setCurrentIndex(idx); + m_viewport->loadReferenceFile(file); + }); + connect(m_refModelCombo, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int idx) { + if (!m_refModelCheck->isChecked()) return; + if (idx <= 0) { + m_viewport->clearReference(); + } else { + QString path = m_refModelCombo->currentData().toString(); + if (!path.isEmpty()) + m_viewport->loadReferenceFile(path); + } + }); + + connect(m_animCombo, QOverload::of(&QComboBox::activated), this, [this](int idx) { + QString name = (idx <= 0) ? QString() : m_animCombo->itemText(idx); + m_viewport->playAnimation(name); + }); + connect(m_viewport, &ModelViewport::animationsAvailable, this, + [this](const QStringList &names, const QString &nowPlaying) { + // Block signals while we rebuild โ€” the combo's activated signal is + // intentional ("user picked one") and shouldn't fire from setup. + QSignalBlocker block(m_animCombo); + m_animCombo->clear(); + m_animCombo->addItem("(bind pose)"); + for (const QString &n : names) + m_animCombo->addItem(n); + m_animCombo->setEnabled(!names.isEmpty()); + if (!nowPlaying.isEmpty()) { + int idx = m_animCombo->findText(nowPlaying); + if (idx >= 0) + m_animCombo->setCurrentIndex(idx); + } else { + m_animCombo->setCurrentIndex(0); + } + }); + + connect(m_inDirectory, &QLineEdit::editingFinished, this, &MainWindow::populateRefModelCombo); + + connect(m_radioDecompile, &QRadioButton::toggled, this, &MainWindow::updateModeUI); + connect(m_radioCompile, &QRadioButton::toggled, this, &MainWindow::updateModeUI); + + connect(m_filesTable, &QTableWidget::customContextMenuRequested, this, [this](const QPoint &pos) { + if (m_filesTable->selectedItems().isEmpty()) return; + + QStringList selectedFiles = collectTableFilePaths(false); + QString firstFile = selectedFiles.isEmpty() ? QString() : selectedFiles.first(); + QMenu menu; + if (selectedFiles.size() == 1) { + menu.addAction("Preview in viewport", this, [this, firstFile] { + if (m_viewport && !firstFile.isEmpty()) + m_viewport->previewFile(firstFile); + }); + } + menu.addAction("Copy path to clipboard", this, &MainWindow::copyToClipboard); + menu.addSeparator(); + + QString reportLabel = selectedFiles.size() == 1 + ? "Report Issue..." + : QString("Report Issue (%1 files)...").arg(selectedFiles.size()); + menu.addAction(reportLabel, this, [this, selectedFiles] { + reportIssueInteractive(selectedFiles); + }); + menu.addSeparator(); + + if (selectedFiles.size() == 1) { + menu.addAction("Reveal in Finder", this, [firstFile] { + QProcess::startDetached("open", {"-R", firstFile}); + }); + } + menu.exec(m_filesTable->mapToGlobal(pos)); + }); + + connect(m_filesTable, &QTableWidget::doubleClicked, this, [this](const QModelIndex &index) { + QString cellText = index.siblingAtColumn(0).data(Qt::UserRole).toString(); + m_debugTextBrowser->moveCursor(QTextCursor::Start); + if (!m_debugTextBrowser->find(cellText)) + m_debugTextBrowser->moveCursor(QTextCursor::End); + if (m_viewport && !cellText.isEmpty() && !m_sInDir.isEmpty()) + m_viewport->previewFile(m_sInDir + "/" + cellText); + }); + + connect(m_indirButton, &QPushButton::clicked, this, [this] { + QString dir = QFileDialog::getExistingDirectory(this, "Input Directory", m_sInDir); + if (!dir.isEmpty()) onUpdateInDir(dir); + }); + connect(m_outdirButton, &QPushButton::clicked, this, [this] { + QString dir = QFileDialog::getExistingDirectory(this, "Output Directory", m_sOutDir); + if (!dir.isEmpty()) { m_outDirectory->setText(dir); m_sOutDir = dir; } + }); + connect(m_inDirectory, &QLineEdit::editingFinished, this, [this] { + QFileInfo fi(m_inDirectory->text()); + if (m_inDirectory->text().isEmpty()) { + m_inDirectory->setStyleSheet(""); + m_inDirectory->setToolTip(""); + m_cleanButton->setEnabled(false); + } else if (fi.exists() && fi.isDir()) { + m_inDirectory->setStyleSheet(""); + m_inDirectory->setToolTip(fi.absoluteFilePath()); + m_cleanButton->setEnabled(true); + onUpdateInDir(m_inDirectory->text()); + } else { + m_inDirectory->setStyleSheet(QStringLiteral("color: ") + LogColor::InvalidPath); + m_inDirectory->setToolTip("Directory does not exist"); + m_cleanButton->setEnabled(false); + } + }); + connect(m_outDirectory, &QLineEdit::editingFinished, this, [this] { m_sOutDir = m_outDirectory->text(); }); + connect(m_filePattern, &QLineEdit::textChanged, this, [this] { + m_dirWatcherTimer->stop(); + m_bFilesHaveChanged = false; + updateFileListing(); + m_dirWatcherTimer->start(); + }); + m_cleanButton->setShortcut(QKeySequence(Qt::Key_F5)); + connect(m_cleanButton, &QPushButton::clicked, this, [this] { doClean(); }); + + // Ctrl+Return alternate trigger โ€” F5 isn't reachable on laptops with + // function-locked keyboards (Apple notebooks, many ultrabooks). Mirrors + // the macOS convention of โŒ˜โ†ต for "primary action in this window". + auto *cleanReturnShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Return), this); + cleanReturnShortcut->setContext(Qt::WindowShortcut); + connect(cleanReturnShortcut, &QShortcut::activated, this, [this] { + if (m_cleanButton->isEnabled()) doClean(); + }); + auto *cleanEnterShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Enter), this); + cleanEnterShortcut->setContext(Qt::WindowShortcut); + connect(cleanEnterShortcut, &QShortcut::activated, this, [this] { + if (m_cleanButton->isEnabled()) doClean(); + }); + + // Esc aborts an in-flight run. No-op when nothing is running, so it + // doesn't steal focus from dialogs or text fields with their own Esc + // handling (Qt::WindowShortcut respects modal dialogs above this window). + auto *abortShortcut = new QShortcut(QKeySequence(Qt::Key_Escape), this); + abortShortcut->setContext(Qt::WindowShortcut); + connect(abortShortcut, &QShortcut::activated, this, [this] { + if (m_bCleanRunning) doClean(); + }); + + // โ”€โ”€ Detail panel / sidebar / raw log connections โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + connect(m_filesTable, &QTableWidget::currentCellChanged, this, [this](int row, int, int, int) { + if (row >= 0 && row < m_filesTable->rowCount()) { + QString fileName = m_filesTable->item(row, 0)->data(Qt::UserRole).toString(); + showFileDetails(fileName); + } + }); + + connect(m_sidebarToggleBtn, &QPushButton::clicked, this, &MainWindow::toggleSidebar); + connect(ui->actionToggleSidebar, &QAction::triggered, this, &MainWindow::toggleSidebar); + connect(ui->actionToggleRawLog, &QAction::triggered, this, &MainWindow::toggleRawLog); +} + +// --------------------------------------------------------------------------- +// Constructor +// --------------------------------------------------------------------------- MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), ui(new Ui::MainWindow) { ui->setupUi(this); - ui->debugTextBrowser->insertHtml(tr("Welcome to Clean Models:EE QT!
    ")); -#ifdef Q_OS_WIN - m_sBinaryName = "cleanmodels-cli.exe"; -#else - m_sBinaryName = "cleanmodels-cli"; -#endif + // Icons + m_iconCleanButton = style()->standardIcon(QStyle::SP_MediaPlay); + m_iconAbortButton = style()->standardIcon(QStyle::SP_DialogCancelButton); + m_iconDecompileButton = style()->standardIcon(QStyle::SP_FileIcon); + ui->actionLoadPreset->setIcon(style()->standardIcon(QStyle::SP_DialogOpenButton)); + ui->actionSavePreset->setIcon(style()->standardIcon(QStyle::SP_DialogSaveButton)); + ui->actionHelp->setIcon(style()->standardIcon(QStyle::SP_DialogHelpButton)); + + m_sBinaryName = CliDefaults::BinaryName; bool cliFound = true; auto cliInPath = QStandardPaths::findExecutable(m_sBinaryName); @@ -46,1418 +987,822 @@ MainWindow::MainWindow(QWidget *parent) : } if (cliFound) - { m_sBinaryPath = cliInPath; - QString foundMsg = "Clean Models Command Line Interface found at " % m_sBinaryPath; - ui->debugTextBrowser->insertHtml(tr(foundMsg.toStdString().c_str())); - auto sb = ui->debugTextBrowser->verticalScrollBar(); - sb->setValue(sb->maximum()); + + // Build the UI + buildUi(); + + if (cliFound) + { + appendDebugHtml("Clean Models:EE ready.
    "); + appendDebugHtml("CLI: " + m_sBinaryPath.toHtmlEscaped() + "
    "); + + // Populate the validation check tree from CLI metadata + auto *listProc = new QProcess(this); + connect(listProc, QOverload::of(&QProcess::finished), + this, [this, listProc](int exitCode, QProcess::ExitStatus) { + listProc->deleteLater(); + if (exitCode != 0) return; + QByteArray output = listProc->readAllStandardOutput(); + QJsonParseError err; + QJsonDocument doc = QJsonDocument::fromJson(output, &err); + if (err.error != QJsonParseError::NoError || !doc.isArray()) return; + populateCheckTree(doc.array()); + }); + listProc->start(m_sBinaryPath, {"check", "--list"}); } else { QString errorMsg = "Could not find the " % m_sBinaryName % " executable in the current directory or in your path!"; - QMessageBox::critical(nullptr, "No cleanmodels-cli", tr(errorMsg.toStdString().c_str())); - ui->debugTextBrowser->insertHtml(tr(errorMsg.toStdString().c_str())); - auto sb = ui->debugTextBrowser->verticalScrollBar(); - sb->setValue(sb->maximum()); + QMessageBox::critical(this, "No cleanmodels CLI", errorMsg); + appendDebugHtml(LogHtml::span(LogColor::Error, errorMsg.toHtmlEscaped()) % QStringLiteral("
    ")); } + m_viewport->setCliBinaryPath(m_sBinaryPath); + + connect(m_viewport, &ModelViewport::previewError, this, [this](const QString &msg) { + appendDebugHtml(LogHtml::logLine(LogColor::Warning, QStringLiteral("Preview: ") % msg.toHtmlEscaped())); + }); + connect(m_viewport, &ModelViewport::previewWarning, this, [this](const QString &msg) { + // Distinct from previewError: model loaded successfully, but the + // parser hit a defensive cap and dropped content. Surface it so + // the user knows their scene is partial rather than guessing + // why limbs/props are missing. + appendDebugHtml(LogHtml::logLine(LogColor::Warning, QStringLiteral("Preview warning: ") % msg.toHtmlEscaped())); + }); + + // Process m_pCleanProcess = new QProcess(this); m_bCleanRunning = false; - m_sLastDirsPath = QCoreApplication::applicationDirPath() % "/last_dirs.pl"; - bool fileExists = QFileInfo::exists(m_sLastDirsPath) && QFileInfo(m_sLastDirsPath).isFile(); - if (!fileExists) - { - QFile fromResource(":/last_dirs.pl"); - fromResource.copy(m_sLastDirsPath); - QFile out(m_sLastDirsPath); - out.setPermissions(QFileDevice::ReadOwner | QFileDevice::ReadGroup | QFileDevice::ReadOther | QFileDevice::WriteOwner | QFileDevice::WriteGroup); - } - auto* sStatusLabel = new QLabel( QString( tr("Status:") ) ); - m_pCleanStatus = new QLabel( QString( tr("Idle") ) ); - m_pStatusProgress = new QProgressBar(); + // Status bar + auto *sStatusLabel = new QLabel("Status:"); + m_pCleanStatus = new QLabel("Idle"); + m_pStatusProgress = new QProgressBar; m_pStatusProgress->setRange(0, 0); m_pStatusProgress->setTextVisible(false); m_pStatusProgress->setVisible(false); - m_pStatusProgress->setMaximumHeight(12); - m_pStatusProgress->setMaximumWidth(100); - - statusBar()->addPermanentWidget( m_pStatusProgress ); - statusBar()->addPermanentWidget( sStatusLabel ); - statusBar()->addPermanentWidget( m_pCleanStatus ); + { + const QFontMetrics fm(m_pStatusProgress->font()); + m_pStatusProgress->setMaximumHeight(Layout::emH(fm, Layout::StatusProgressHeightEm)); + m_pStatusProgress->setMaximumWidth(Layout::emW(fm, Layout::StatusProgressWidthChars)); + } + statusBar()->addPermanentWidget(sStatusLabel); + statusBar()->addPermanentWidget(m_pCleanStatus); + statusBar()->addPermanentWidget(m_pStatusProgress); + // Dir completer m_pDirCompleter = new QCompleter(this); m_pDirCompleter->setMaxVisibleItems(4); m_pFileSystemModel = new FileSystemModel(m_pDirCompleter); - m_pFileSystemModel->setFilter(QDir::Dirs|QDir::Drives|QDir::NoDotAndDotDot|QDir::AllDirs); + m_pFileSystemModel->setFilter(QDir::Dirs | QDir::Drives | QDir::NoDotAndDotDot | QDir::AllDirs); m_pDirCompleter->setModel(m_pFileSystemModel); - ui->inDirectory->setCompleter(m_pDirCompleter); - ui->outDirectory->setCompleter(m_pDirCompleter); - - ui->filesTable->setColumnCount(5); - ui->filesTable->setColumnWidth(1, 100); - ui->filesTable->setColumnWidth(2, 140); - ui->filesTable->setColumnWidth(3, 70); - ui->filesTable->setColumnWidth(4, 100); - ui->filesTable->setHorizontalHeaderLabels({"File", "Size", "Status", "Fixes", "Time"}); - ui->filesTable->setAlternatingRowColors(true); - ui->filesTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Stretch); - ui->filesTable->horizontalHeader()->setVisible(true); - ui->filesTable->verticalHeader()->setDefaultSectionSize(20); - ui->filesTable->verticalHeader()->setVisible(false); - ui->filesTable->setContextMenuPolicy(Qt::CustomContextMenu); - ui->filesTable->setSelectionMode(QAbstractItemView::SingleSelection); - ui->filesTable->setSelectionBehavior(QAbstractItemView::SelectRows); - - m_iconReadingMDL = QIcon(":icons/reading-mdl"); - m_iconDecompilingMDL = QIcon(":icons/decompiling-mdl"); - m_iconCleaningMDL = QIcon(":icons/cleaning-mdl"); - m_iconCleanSuccess = QIcon(":icons/clean-success"); - m_iconCleanError = QIcon(":icons/clean-error"); - m_iconCleanButton = QIcon(":icons/clean-button"); - m_iconASCIIMdl = QIcon(":icons/mdl-ascii"); - m_iconBinaryMdl = QIcon(":icons/mdl-binary"); - m_iconAbortButton = QIcon(":icons/abort-button"); - m_iconDecompileButton = QIcon(":icons/decompile-button"); - m_iconLockRescaleBtn = QIcon(":icons/lock-rescale"); - m_iconUnlockRescaleBtn = QIcon(":icons/unlock-rescale"); - ui->actionLoadPreset->setIcon(QIcon(":icons/load-preset")); - ui->actionSavePreset->setIcon(QIcon(":icons/save-preset")); - ui->actionHelp->setIcon(QIcon(":icons/whats-this")); - ui->indirButton->setIcon(QIcon(":icons/indir")); - ui->outdirButton->setIcon(QIcon(":icons/outdir")); - ui->cleanButton->setIcon(m_iconCleanButton); + m_inDirectory->setCompleter(m_pDirCompleter); + m_outDirectory->setCompleter(m_pDirCompleter); + // Dir watcher m_dirWatcherTimer = new QTimer(this); m_dirWatcherTimer->setInterval(500); - connect(m_dirWatcherTimer, &QTimer::timeout, this, QOverload<>::of(&MainWindow::handleDirWatcherTimer)); + connect(m_dirWatcherTimer, &QTimer::timeout, this, &MainWindow::handleDirWatcherTimer); m_bUpdateFilesAfterClean = false; - readInLastDirs(m_sLastDirsPath); - QObject::connect(m_pCleanProcess, static_cast(&QProcess::finished), this, &MainWindow::onCleanFinished); - QObject::connect(ui->actionHelp, SIGNAL(triggered()), this, SLOT(onHelpTriggered())); - QObject::connect(ui->actionAbout, SIGNAL(triggered()), this, SLOT(onAboutTriggered())); - QObject::connect(ui->actionSavePreset, SIGNAL(triggered()), this, SLOT(onSaveConfigTriggered())); - QObject::connect(ui->actionLoadPreset, SIGNAL(triggered()), this, SLOT(onLoadConfigTriggered())); - QObject::connect(ui->actionQuit, SIGNAL(triggered()), this, SLOT(onQuitTriggered())); - QObject::connect(&m_fsWatcher, SIGNAL(directoryChanged(QString)), this, SLOT(onDirectoryContentsChanged())); + // Signals + connect(m_pCleanProcess, &QProcess::started, this, &MainWindow::onCleanStarted); + connect(m_pCleanProcess, &QProcess::errorOccurred, this, &MainWindow::onCleanProcessError); + connect(m_pCleanProcess, &QProcess::finished, this, &MainWindow::onCleanFinished); + connect(ui->actionHelp, &QAction::triggered, this, &MainWindow::onHelpTriggered); + connect(ui->actionReportIssue, &QAction::triggered, this, &MainWindow::onReportIssueTriggered); + connect(ui->actionAbout, &QAction::triggered, this, &MainWindow::onAboutTriggered); + connect(ui->actionSavePreset, &QAction::triggered, this, &MainWindow::onSaveConfigTriggered); + connect(ui->actionLoadPreset, &QAction::triggered, this, &MainWindow::onLoadConfigTriggered); + connect(ui->actionQuit, &QAction::triggered, this, &MainWindow::onQuitTriggered); + connect(&m_fsWatcher, &QFileSystemWatcher::directoryChanged, this, &MainWindow::onDirectoryContentsChanged); + + setAcceptDrops(true); + + loadSettings(); readSettings(); } MainWindow::~MainWindow() { + if (m_pCleanProcess->state() != QProcess::NotRunning) { + m_pCleanProcess->kill(); + m_pCleanProcess->waitForFinished(3000); + } delete ui; - m_pCleanProcess->close(); - delete m_pCleanProcess; } -// Window Position/Geometry +// --------------------------------------------------------------------------- +// Settings +// --------------------------------------------------------------------------- void MainWindow::readSettings() { QScreen *screen = QGuiApplication::primaryScreen(); if (const QWindow *window = windowHandle()) screen = window->screen(); - if (!screen) - return; + if (!screen) return; QSettings settings(QCoreApplication::organizationName(), QCoreApplication::applicationName()); - const QByteArray geometry = settings.value("geometry", QByteArray()).toByteArray(); + const QByteArray geometry = settings.value(Setting::Geometry, QByteArray()).toByteArray(); if (geometry.isEmpty()) { - const QRect availableGeometry = screen->availableGeometry(); - resize(availableGeometry.width() / 3, availableGeometry.height() / 2); - move((availableGeometry.width() - width()) / 2, - (availableGeometry.height() - height()) / 2); + const QRect avail = screen->availableGeometry(); + resize(avail.width() / 2, avail.height() * 2 / 3); + move((avail.width() - width()) / 2, (avail.height() - height()) / 2); } else - { restoreGeometry(geometry); - } } void MainWindow::writeSettings() { QSettings settings(QCoreApplication::organizationName(), QCoreApplication::applicationName()); - settings.setValue("geometry", saveGeometry()); + settings.setValue(Setting::Geometry, saveGeometry()); } -void MainWindow::closeEvent(QCloseEvent*) +void MainWindow::loadSettings() { - writeSettings(); -} + QSettings s(QCoreApplication::organizationName(), QCoreApplication::applicationName()); + s.beginGroup(Setting::OptionsGroup); -// Main last_dirs parsing and writing functions -void MainWindow::readInLastDirs(const QString& fileLoc) -{ - QFile inputFile(fileLoc); - if (inputFile.open(QIODevice::ReadOnly)) + QString inDir = s.value(Setting::InDir).toString(); + if (!inDir.isEmpty()) { - QTextStream in(&inputFile); - while (!in.atEnd()) - { - QString line = in.readLine(); - QString str = R"(^:-asserta\((.*)\((.*)[,]?(\w+|\[.*\])?\)\)\.$)"; - - QRegularExpression re(str, QRegularExpression::InvertedGreedinessOption | QRegularExpression::MultilineOption); - QRegularExpressionMatchIterator i = re.globalMatch(line); - while (i.hasNext()) - { - QRegularExpressionMatch match = i.next(); - QString captured = match.captured(1); - QString capturedTwo = match.captured(2).isEmpty() ? match.captured(3) : match.captured(2); - if (captured.isEmpty() || capturedTwo.isEmpty()) - break; - if (capturedTwo.startsWith("'")) - capturedTwo.remove(0,1); - if (capturedTwo.endsWith("'")) - capturedTwo.chop(1); - if (captured == "g_indir") - { - onUpdateInDir(capturedTwo); - QDir absDir; - m_pFileSystemModel->setRootPath(absDir.absoluteFilePath(capturedTwo)); - } - else if (captured == "g_outdir") - { - m_sOutDir = capturedTwo; - ui->outDirectory->setText(m_sOutDir); - QDir absDir; - m_pFileSystemModel->setRootPath(absDir.absoluteFilePath(m_sOutDir)); - } - else if (captured == "g_pattern") - { - ui->filePattern->setText(capturedTwo); - } - else if (captured == "g_logfile") - { - ui->logFileName->setText(capturedTwo); - } - else if (captured == "g_small_log") - { - ui->summaryLogFileName->setText(capturedTwo); - } - else if (captured == "g_user_option") - { - QString value = match.captured(3); - if (capturedTwo == "classification") - { - if (value == "character") - ui->modelClassCombo->setCurrentIndex(1); - else if (value == "door") - ui->modelClassCombo->setCurrentIndex(2); - else if (value == "effect") - ui->modelClassCombo->setCurrentIndex(3); - else if (value == "item") - ui->modelClassCombo->setCurrentIndex(4); - else if (value == "tile") - ui->modelClassCombo->setCurrentIndex(5); - else - ui->modelClassCombo->setCurrentIndex(0); - } - else if (capturedTwo == "snap") - { - if (value == "binary") - ui->snapCombo->setCurrentIndex(1); - else if (value == "decimal") - ui->snapCombo->setCurrentIndex(2); - else if (value == "fine") - ui->snapCombo->setCurrentIndex(3); - else - ui->snapCombo->setCurrentIndex(0); - } - else if (capturedTwo == "tvert_snap") - { - if (value == "256") - ui->snapTVertsCombo->setCurrentIndex(1); - else if (value == "512") - ui->snapTVertsCombo->setCurrentIndex(2); - else if (value == "1024") - ui->snapTVertsCombo->setCurrentIndex(3); - else - ui->snapTVertsCombo->setCurrentIndex(0); - } - else if (capturedTwo == "use_Smoothed") - { - if (value == "ignore") - ui->smoothingGroupsCombo->setCurrentIndex(1); - else if (value == "protect") - ui->smoothingGroupsCombo->setCurrentIndex(2); - else - ui->smoothingGroupsCombo->setCurrentIndex(0); - } - else if (capturedTwo == "fix_overhangs") - { - if (value == "yes") - ui->repairAABBCombo->setCurrentIndex(1); - else if (value == "interior_only") - ui->repairAABBCombo->setCurrentIndex(2); - else - ui->repairAABBCombo->setCurrentIndex(0); - } - else if (capturedTwo == "dynamic_water") - { - if (value == "no") - ui->dynamicWaterCombo->setCurrentIndex(1); - else if (value == "wavy") - ui->dynamicWaterCombo->setCurrentIndex(2); - else - ui->dynamicWaterCombo->setCurrentIndex(0); - } - else if (capturedTwo == "rotate_water") - { - if (value == "1") - ui->waterRotateTextureCombo->setCurrentIndex(1); - else if (value == "0") - ui->waterRotateTextureCombo->setCurrentIndex(2); - else - ui->waterRotateTextureCombo->setCurrentIndex(0); - } - else if (capturedTwo == "tile_water") - { - if (value == "1") - ui->retileWaterCombo->setCurrentIndex(1); - else if (value == "2") - ui->retileWaterCombo->setCurrentIndex(2); - else if (value == "3") - ui->retileWaterCombo->setCurrentIndex(3); - else - ui->retileWaterCombo->setCurrentIndex(0); - } - else if (capturedTwo == "tile_raise") - { - if (value == "raise") - ui->raiseLowerCombo->setCurrentIndex(1); - else if (value == "lower") - ui->raiseLowerCombo->setCurrentIndex(2); - else - ui->raiseLowerCombo->setCurrentIndex(0); - } - else if (capturedTwo == "slice") - { - if (value == "no") - ui->sliceForTileFadeCombo->setCurrentIndex(1); - else if (value == "undo") - ui->sliceForTileFadeCombo->setCurrentIndex(2); - else - ui->sliceForTileFadeCombo->setCurrentIndex(0); - } - else if (capturedTwo == "tile_raise_amount") - { - ui->raiseLowerAmountSpin->setValue(value.toDouble()); - } - else if (capturedTwo == "render") - { - if (value == "all") - ui->renderTrimeshCombo->setCurrentIndex(1); - else if (value == "none") - ui->renderTrimeshCombo->setCurrentIndex(2); - else - ui->renderTrimeshCombo->setCurrentIndex(0); - } - else if (capturedTwo == "shadow") - { - if (value == "all") - ui->renderShadowsCombo->setCurrentIndex(1); - else if (value == "none") - ui->renderShadowsCombo->setCurrentIndex(2); - else - ui->renderShadowsCombo->setCurrentIndex(0); - } - else if (capturedTwo == "repivot") - { - if (value == "all") - ui->repivotCombo->setCurrentIndex(1); - else if (value == "none") - ui->repivotCombo->setCurrentIndex(2); - else - ui->repivotCombo->setCurrentIndex(0); - } - else if (capturedTwo == "pivots_below_z=0") - { - if (value == "allow") - ui->pivotsBelowZeroZCombo->setCurrentIndex(1); - else if (value == "slice") - ui->pivotsBelowZeroZCombo->setCurrentIndex(2); - else - ui->pivotsBelowZeroZCombo->setCurrentIndex(0); - } - else if (capturedTwo == "move_bad_pivots") - { - if (value == "top") - ui->moveBadPivotsCombo->setCurrentIndex(1); - else if (value == "middle") - ui->moveBadPivotsCombo->setCurrentIndex(2); - else if (value == "bottom") - ui->moveBadPivotsCombo->setCurrentIndex(3); - else - ui->moveBadPivotsCombo->setCurrentIndex(0); - } - else if (capturedTwo == "foliage") - { - if (value == "tilefade") - ui->foliageCombo->setCurrentIndex(1); - else if (value == "animate") - ui->foliageCombo->setCurrentIndex(2); - else if (value == "de-animate") - ui->foliageCombo->setCurrentIndex(3); - else if (value == "ignore") - ui->foliageCombo->setCurrentIndex(4); - else - ui->foliageCombo->setCurrentIndex(0); - } - else if (capturedTwo == "rotate_ground") - { - if (value == "1") - ui->groundRotateTextureCombo->setCurrentIndex(1); - else if (value == "0") - ui->groundRotateTextureCombo->setCurrentIndex(2); - else - ui->groundRotateTextureCombo->setCurrentIndex(0); - } - else if (capturedTwo == "chamfer") - { - if (value == "add") - ui->tileEdgeChamfersCombo->setCurrentIndex(1); - else if (value == "delete") - ui->tileEdgeChamfersCombo->setCurrentIndex(2); - else - ui->tileEdgeChamfersCombo->setCurrentIndex(0); - } - else if (capturedTwo == "tile_ground") - { - if (value == "1") - ui->retileGroundPlanesCombo->setCurrentIndex(1); - else if (value == "2") - ui->retileGroundPlanesCombo->setCurrentIndex(2); - else if (value == "3") - ui->retileGroundPlanesCombo->setCurrentIndex(3); - else - ui->retileGroundPlanesCombo->setCurrentIndex(0); - } - else if (capturedTwo == "invisible_mesh_cull") - { - ui->cullInvisibleCheck->setChecked(value == "yes"); - } - else if (capturedTwo == "map_aabb_material") - { - ui->changeWokMatCheck->setChecked(value == "yes"); - ui->changeWokMatGroupBox->setEnabled(value == "yes"); - } - else if (capturedTwo == "allow_split") - { - ui->allowSplittingCheck->setChecked(value == "yes"); - } - else if (capturedTwo == "do_water") - { - ui->waterFixupsCheck->setChecked(value == "yes"); - ui->waterFrame->setEnabled(value == "yes"); - } - else if (capturedTwo == "water_key") - { - ui->waterBitmapKeys->setText(value); - } - else if (capturedTwo == "ground_key") - { - ui->groundBitmapKeys->setText(value); - } - else if (capturedTwo == "splotch_key") - { - ui->splotchBitmapKeys->setText(value); - } - else if (capturedTwo == "foliage_key") - { - ui->foliageBitmapKeys->setText(value); - } - else if (capturedTwo == "min_Size") - { - ui->subObjectSpin->setValue(value.toInt()); - } - else if (capturedTwo == "merge_by_bitmap") - { - ui->meshMergeCheck->setChecked(value == "yes"); - } - else if (capturedTwo == "placeable_with_transparency") - { - ui->placeableWithTransparencyCheck->setChecked(value == "yes"); - ui->transBitmapKeyFrame->setEnabled(value == "yes"); - } - else if (capturedTwo == "splotch") - { - ui->animateSplotchesCheck->setChecked(value == "animate"); - ui->splotchBitmapKeysLabel->setEnabled(value == "animate"); - ui->splotchBitmapKeys->setEnabled(value == "animate"); - } - else if (capturedTwo == "force_white") - { - ui->forceWhiteCheck->setChecked(value == "yes"); - } - else if (capturedTwo == "split_Priority") - { - ui->forceWhiteCheck->setChecked(value == "concave"); - } - else if (capturedTwo == "transparency_key") - { - ui->transparentBitmapKeys->setText(value); - } - else if (capturedTwo == "wave_height") - { - ui->waveHeightSpin->setValue(value.toDouble()); - } - else if (capturedTwo == "map_aabb_from") - { - ui->changeWokMatFromSpin->setValue(value.toInt()); - } - else if (capturedTwo == "map_aabb_to") - { - ui->changeWokMatToSpin->setValue(value.toInt()); - } - else if (capturedTwo == "rescaleXYZ") - { - double X = 1.0; - double Y = 1.0; - double Z = 1.0; - if (value != "no") - { - auto scales = value.mid(1,value.length() - 2).split(","); - X = scales[0].toDouble(nullptr); - Y = scales[1].toDouble(nullptr); - Z = scales[2].toDouble(nullptr); - } - ui->rescaleXSpin->setValue(X); - ui->rescaleYSpin->setValue(Y); - ui->rescaleZSpin->setValue(Z); - } - } - } - } - inputFile.close(); + onUpdateInDir(inDir); + QDir absDir; + m_pFileSystemModel->setRootPath(absDir.absoluteFilePath(inDir)); } -} -void MainWindow::replaceUserOption(const QString& key, const QString& value, bool coreVal) -{ - QString str, rpl; - if (!coreVal) + QString outDir = s.value(Setting::OutDir).toString(); + if (!outDir.isEmpty()) { - str = "^:-asserta\\(g_user_option\\(" % key % R"(,(.*)\)\)\.$)"; - rpl = ":-asserta(g_user_option(" % key % "," % value % "))."; + m_sOutDir = outDir; + m_outDirectory->setText(m_sOutDir); } - else - { - str = "^:-asserta\\(" % key % R"((.*)\)\)\.$)"; - rpl = ":-asserta(" % key % "('" % value % "'))."; + + m_filePattern->setText(s.value(Setting::Pattern, "*.mdl").toString()); + m_classificationCombo->setCurrentIndex(s.value(Setting::Classification, 0).toInt()); + + // Fixes + m_allFixesCheck->setChecked(s.value(Setting::AllFixes, true).toBool()); + m_checkValidateAll->setChecked(s.value(Setting::FixValidate, true).toBool()); + m_checkStripDegen->setChecked(s.value(Setting::FixStripDegen, true).toBool()); + m_checkFixAnims->setChecked(s.value(Setting::FixAnimations, true).toBool()); + m_checkRepairPivots->setChecked(s.value(Setting::FixPivots, true).toBool()); + m_checkFixTilefade->setChecked(s.value(Setting::FixTilefade, true).toBool()); + m_checkTilefadeUndo->setChecked(s.value(Setting::TilefadeUndo, false).toBool()); + m_checkRebuildAABB->setChecked(s.value(Setting::FixAabb, true).toBool()); + m_checkReparentChildren->setChecked(s.value(Setting::FixReparent, true).toBool()); + m_checkWrapRoot->setChecked(s.value(Setting::FixWrapRoot, true).toBool()); + m_checkSplitMultiEdge->setChecked(s.value(Setting::FixSplitMultiedge, true).toBool()); + + // Advanced + m_scaleXSpin->setValue(s.value(Setting::RescaleX, 1.0).toDouble()); + m_scaleYSpin->setValue(s.value(Setting::RescaleY, 1.0).toDouble()); + m_scaleZSpin->setValue(s.value(Setting::RescaleZ, 1.0).toDouble()); + m_snapCombo->setCurrentIndex(s.value(Setting::Snap, 0).toInt()); + m_tvertSnapCombo->setCurrentIndex(s.value(Setting::TvertSnap, 0).toInt()); + m_renderCombo->setCurrentIndex(s.value(Setting::RenderMode, 0).toInt()); + m_shadowCombo->setCurrentIndex(s.value(Setting::ShadowMode, 0).toInt()); + m_forceWhiteCheck->setChecked(s.value(Setting::ForceWhite, false).toBool()); + m_mergeByBitmapCheck->setChecked(s.value(Setting::MergeByBitmap, false).toBool()); + m_cullInvisibleCheck->setChecked(s.value(Setting::InvisibleMeshCull, false).toBool()); + m_standardizeTexture0Check->setChecked(s.value(Setting::StandardizeTexture0, false).toBool()); + m_stripEEExtrasCheck->setChecked(s.value(Setting::StripEEExtras, false).toBool()); + m_placeableTransCheck->setChecked(s.value(Setting::PlaceableTrans, false).toBool()); + m_transparencyKeyEdit->setText(s.value(Setting::TransparencyKey, "glass").toString()); + + // Tiles + m_sliceHeightSpin->setValue(s.value(Setting::SliceHeight, 20.0).toDouble()); + m_waterEnableCheck->setChecked(s.value(Setting::DoWater, false).toBool()); + m_dynamicWaterCombo->setCurrentIndex(s.value(Setting::DynamicWater, 0).toInt()); + m_waveHeightSpin->setValue(s.value(Setting::WaveHeight, 0.1).toDouble()); + m_waterKeyEdit->setText(s.value(Setting::WaterKey, "water").toString()); + m_rotateWaterCombo->setCurrentIndex(s.value(Setting::RotateWater, 0).toInt()); + m_retileWaterCombo->setCurrentIndex(s.value(Setting::TileWater, 0).toInt()); + m_foliageCombo->setCurrentIndex(s.value(Setting::Foliage, 0).toInt()); + m_foliageKeyEdit->setText(s.value(Setting::FoliageKey, "trefol").toString()); + m_animateSplotchesCheck->setChecked(s.value(Setting::AnimateSplotches, false).toBool()); + m_splotchKeyEdit->setText(s.value(Setting::SplotchKey).toString()); + m_rotateGroundCombo->setCurrentIndex(s.value(Setting::RotateGround, 0).toInt()); + m_chamferCombo->setCurrentIndex(s.value(Setting::ChamferMode, 0).toInt()); + m_retileGroundCombo->setCurrentIndex(s.value(Setting::TileGround, 0).toInt()); + m_groundKeyEdit->setText(s.value(Setting::GroundKey).toString()); + m_raiseLowerCombo->setCurrentIndex(s.value(Setting::TileRaise, 0).toInt()); + m_raiseAmountSpin->setValue(s.value(Setting::TileRaiseAmount, 1.0).toDouble()); + m_remapWokMatCheck->setChecked(s.value(Setting::MapAabbMaterial, false).toBool()); + m_wokMatFromSpin->setValue(s.value(Setting::MapAabbFrom, 0).toInt()); + m_wokMatToSpin->setValue(s.value(Setting::MapAabbTo, 0).toInt()); + + // Pivots + m_pivotAllowSplitCheck->setChecked(s.value(Setting::AllowSplit, false).toBool()); + m_pivotBelowZ0Combo->setCurrentIndex(s.value(Setting::PivotsBelowZ0, 0).toInt()); + m_pivotMoveBadCombo->setCurrentIndex(s.value(Setting::MoveBadPivots, 0).toInt()); + m_pivotSmoothingCombo->setCurrentIndex(s.value(Setting::SmoothingGroups, 0).toInt()); + m_pivotMinFacesSpin->setValue(s.value(Setting::MinSize, 4).toInt()); + m_pivotSplitFirstCombo->setCurrentIndex(s.value(Setting::SplitFirst, 0).toInt()); + + // Camera + if (m_viewport) { + Camera &cam = m_viewport->camera(); + cam.setRotationSensitivity(s.value(Setting::CameraRotSens, Camera::DefaultRotationSensitivity).toFloat()); + cam.setPanScale(s.value(Setting::CameraPanScale, Camera::DefaultPanScale).toFloat()); + cam.setZoomFactor(s.value(Setting::CameraZoomFactor, Camera::DefaultZoomFactor).toFloat()); } - QFile file(m_sLastDirsPath); - if(!file.open(QIODevice::Text | QIODevice::ReadWrite)) - { - QMessageBox::critical(nullptr, "exception", tr("Could not open last_dirs for saving!")); - return; + + // Layout visibility + bool sidebarVis = s.value(Setting::SidebarVisible, true).toBool(); + m_sidebarWidget->setVisible(sidebarVis); + m_sidebarToggleBtn->setText(sidebarVis ? tr("Hide Sidebar") : tr("Show Sidebar")); + m_sidebarToggleBtn->setVisible(!sidebarVis); + + m_rawLogVisible = s.value(Setting::RawLogVisible, false).toBool(); + m_rawLogDrawer->setVisible(m_rawLogVisible); + + s.endGroup(); +} + +void MainWindow::saveSettings() +{ + QSettings s(QCoreApplication::organizationName(), QCoreApplication::applicationName()); + s.beginGroup(Setting::OptionsGroup); + + s.setValue(Setting::InDir, m_sInDir); + s.setValue(Setting::OutDir, m_sOutDir); + s.setValue(Setting::Pattern, m_filePattern->text()); + s.setValue(Setting::Classification, m_classificationCombo->currentIndex()); + + s.setValue(Setting::AllFixes, m_allFixesCheck->isChecked()); + s.setValue(Setting::FixValidate, m_checkValidateAll->isChecked()); + s.setValue(Setting::FixStripDegen, m_checkStripDegen->isChecked()); + s.setValue(Setting::FixAnimations, m_checkFixAnims->isChecked()); + s.setValue(Setting::FixPivots, m_checkRepairPivots->isChecked()); + s.setValue(Setting::FixTilefade, m_checkFixTilefade->isChecked()); + s.setValue(Setting::TilefadeUndo, m_checkTilefadeUndo->isChecked()); + s.setValue(Setting::FixAabb, m_checkRebuildAABB->isChecked()); + s.setValue(Setting::FixReparent, m_checkReparentChildren->isChecked()); + s.setValue(Setting::FixWrapRoot, m_checkWrapRoot->isChecked()); + s.setValue(Setting::FixSplitMultiedge, m_checkSplitMultiEdge->isChecked()); + + s.setValue(Setting::RescaleX, m_scaleXSpin->value()); + s.setValue(Setting::RescaleY, m_scaleYSpin->value()); + s.setValue(Setting::RescaleZ, m_scaleZSpin->value()); + s.setValue(Setting::Snap, m_snapCombo->currentIndex()); + s.setValue(Setting::TvertSnap, m_tvertSnapCombo->currentIndex()); + s.setValue(Setting::RenderMode, m_renderCombo->currentIndex()); + s.setValue(Setting::ShadowMode, m_shadowCombo->currentIndex()); + s.setValue(Setting::ForceWhite, m_forceWhiteCheck->isChecked()); + s.setValue(Setting::MergeByBitmap, m_mergeByBitmapCheck->isChecked()); + s.setValue(Setting::InvisibleMeshCull, m_cullInvisibleCheck->isChecked()); + s.setValue(Setting::StandardizeTexture0, m_standardizeTexture0Check->isChecked()); + s.setValue(Setting::StripEEExtras, m_stripEEExtrasCheck->isChecked()); + s.setValue(Setting::PlaceableTrans, m_placeableTransCheck->isChecked()); + s.setValue(Setting::TransparencyKey, m_transparencyKeyEdit->text()); + + s.setValue(Setting::SliceHeight, m_sliceHeightSpin->value()); + s.setValue(Setting::DoWater, m_waterEnableCheck->isChecked()); + s.setValue(Setting::DynamicWater, m_dynamicWaterCombo->currentIndex()); + s.setValue(Setting::WaveHeight, m_waveHeightSpin->value()); + s.setValue(Setting::WaterKey, m_waterKeyEdit->text()); + s.setValue(Setting::RotateWater, m_rotateWaterCombo->currentIndex()); + s.setValue(Setting::TileWater, m_retileWaterCombo->currentIndex()); + s.setValue(Setting::Foliage, m_foliageCombo->currentIndex()); + s.setValue(Setting::FoliageKey, m_foliageKeyEdit->text()); + s.setValue(Setting::AnimateSplotches, m_animateSplotchesCheck->isChecked()); + s.setValue(Setting::SplotchKey, m_splotchKeyEdit->text()); + s.setValue(Setting::RotateGround, m_rotateGroundCombo->currentIndex()); + s.setValue(Setting::ChamferMode, m_chamferCombo->currentIndex()); + s.setValue(Setting::TileGround, m_retileGroundCombo->currentIndex()); + s.setValue(Setting::GroundKey, m_groundKeyEdit->text()); + s.setValue(Setting::TileRaise, m_raiseLowerCombo->currentIndex()); + s.setValue(Setting::TileRaiseAmount, m_raiseAmountSpin->value()); + s.setValue(Setting::MapAabbMaterial, m_remapWokMatCheck->isChecked()); + s.setValue(Setting::MapAabbFrom, m_wokMatFromSpin->value()); + s.setValue(Setting::MapAabbTo, m_wokMatToSpin->value()); + + s.setValue(Setting::AllowSplit, m_pivotAllowSplitCheck->isChecked()); + s.setValue(Setting::PivotsBelowZ0, m_pivotBelowZ0Combo->currentIndex()); + s.setValue(Setting::MoveBadPivots, m_pivotMoveBadCombo->currentIndex()); + s.setValue(Setting::SmoothingGroups, m_pivotSmoothingCombo->currentIndex()); + s.setValue(Setting::MinSize, m_pivotMinFacesSpin->value()); + s.setValue(Setting::SplitFirst, m_pivotSplitFirstCombo->currentIndex()); + + // Camera + if (m_viewport) { + const Camera &cam = m_viewport->camera(); + s.setValue(Setting::CameraRotSens, static_cast(cam.rotationSensitivity())); + s.setValue(Setting::CameraPanScale, static_cast(cam.panScale())); + s.setValue(Setting::CameraZoomFactor, static_cast(cam.zoomFactor())); } - QString dataText = file.readAll(); - file.close(); - QRegularExpression re(str, QRegularExpression::InvertedGreedinessOption | QRegularExpression::MultilineOption); - dataText.replace(re, rpl); + // Layout visibility + s.setValue(Setting::SidebarVisible, m_sidebarWidget->isVisible()); + s.setValue(Setting::RawLogVisible, m_rawLogVisible); - if(file.open(QFile::WriteOnly | QFile::Truncate)) - { - QTextStream out(&file); - out << dataText; - } - file.close(); + s.endGroup(); } -// SLOTS/SIGNALS +void MainWindow::closeEvent(QCloseEvent *) +{ + writeSettings(); + saveSettings(); +} + +// --------------------------------------------------------------------------- +// Menu actions +// --------------------------------------------------------------------------- void MainWindow::onLoadConfigTriggered() { - QFileDialog fileDialog; - fileDialog.setAcceptMode(QFileDialog::AcceptMode::AcceptOpen); - QStringList nameFilters; - nameFilters.append("Clean Models Config (*.cm)"); - fileDialog.setNameFilters(nameFilters); + QFileDialog fileDialog(this); + fileDialog.setAcceptMode(QFileDialog::AcceptOpen); + fileDialog.setNameFilters({"Clean Models Config (*.ini)", "Legacy Config (*.cm *.pl)"}); fileDialog.setDirectory(QDir::currentPath()); if (fileDialog.exec()) { QString fileName = fileDialog.selectedFiles()[0]; - QFile file(fileName); - if (!file.open(QIODevice::ReadOnly)) - { - QMessageBox::information(this, tr("Unable to open file"), file.errorString()); - return; - } - else - file.close(); - if (QFile::exists(m_sLastDirsPath)) - { - QFile::remove(m_sLastDirsPath); - } - if (!file.copy(m_sLastDirsPath)) - { - QMessageBox::information(this, tr("Unable to load file"), file.errorString()); - return; - } - else - readInLastDirs(m_sLastDirsPath); + QSettings imported(fileName, QSettings::IniFormat); + QSettings current(QCoreApplication::organizationName(), QCoreApplication::applicationName()); + for (const auto &key : imported.allKeys()) + current.setValue(key, imported.value(key)); + loadSettings(); } } void MainWindow::onSaveConfigTriggered() { - QFileDialog fileDialog; - fileDialog.setAcceptMode(QFileDialog::AcceptMode::AcceptSave); + saveSettings(); + QFileDialog fileDialog(this); + fileDialog.setAcceptMode(QFileDialog::AcceptSave); fileDialog.setFileMode(QFileDialog::AnyFile); - fileDialog.setDefaultSuffix("cm"); - QStringList nameFilters; - nameFilters.append("Clean Models Config (*.cm)"); - fileDialog.setNameFilters(nameFilters); + fileDialog.setDefaultSuffix("ini"); + fileDialog.setNameFilters({"Clean Models Config (*.ini)"}); fileDialog.setDirectory(QDir::currentPath()); if (fileDialog.exec()) { QString fileName = fileDialog.selectedFiles()[0]; - QFile file(fileName); - if (!file.open(QIODevice::WriteOnly)) - { - QMessageBox::information(this, tr("Unable to open file"), file.errorString()); - return; - } - if (QFile::exists(fileName)) - { - QFile::remove(fileName); - } - QFile fromResource(m_sLastDirsPath); - if (fromResource.copy(fileName)) - { - QFile out(fileName); - out.setPermissions(QFileDevice::ReadOwner | QFileDevice::ReadGroup | QFileDevice::ReadOther | QFileDevice::WriteOwner | QFileDevice::WriteGroup); - file.close(); - } - else - { - QMessageBox::information(this, tr("Unable to save file"),fromResource.errorString()); - return; - } + QSettings current(QCoreApplication::organizationName(), QCoreApplication::applicationName()); + QSettings exported(fileName, QSettings::IniFormat); + for (const auto &key : current.allKeys()) + exported.setValue(key, current.value(key)); } } void MainWindow::onAboutTriggered() { - QMessageBox::about(this, tr("About Clean Models:EE QT"), - tr("A front end to Clean Models, a utility to tidy up 3d models\n" - "for usage in Neverwinter Nights: Enhanced Edition.")); + QMessageBox::about(this, "About Clean Models:EE", + "Clean Models:EE\n\n" + "A tool to validate, repair, and compile 3D models\n" + "for Neverwinter Nights: Enhanced Edition.\n\n" + "Powered by cleanmodels (Go CLI).\n" + "Press F5 to clean."); } -void MainWindow::onHelpTriggered() -{ - QWhatsThis::enterWhatsThisMode(); -} +void MainWindow::onHelpTriggered() { QWhatsThis::enterWhatsThisMode(); } -void MainWindow::onQuitTriggered() +QStringList MainWindow::collectTableFilePaths(bool allRows) const { - if (m_bCleanRunning) - m_pCleanProcess->kill(); - - QApplication::quit(); -} - -void MainWindow::copyToClipboard() -{ - auto *selectedFile = ui->filesTable->selectedItems()[0]; - QClipboard *clipboard = QApplication::clipboard(); - clipboard->setText(m_sInDir % "/" % selectedFile->text()); -} - -void MainWindow::on_cleanButton_released() -{ - doClean(); -} - -void MainWindow::on_decompileCheck_stateChanged(int arg1) -{ - if (arg1 == Qt::Unchecked) - { - ui->cleanButton->setText(tr("Clean")); - ui->cleanButton->setIcon(m_iconCleanButton); - ui->mdlsCleanedLabel->setText(tr("Files Cleaned: 0")); - ui->classSnapBox->setDisabled(false); - ui->tilesTab->setDisabled(false); - ui->coreFixesBox->setDisabled(false); - ui->pivotFrame->setDisabled(false); - ui->rescaleFrame->setDisabled(false); - } - else - { - ui->cleanButton->setText(tr("Decompile")); - ui->cleanButton->setIcon(m_iconDecompileButton); - ui->mdlsCleanedLabel->setText(tr("Files Decompiled: 0")); - ui->classSnapBox->setDisabled(true); - ui->tilesTab->setDisabled(true); - ui->coreFixesBox->setDisabled(true); - ui->pivotFrame->setDisabled(true); - ui->rescaleFrame->setDisabled(true); - } -} - -void MainWindow::onDirectoryContentsChanged() -{ - m_dirWatcherTimer->stop(); - m_bFilesHaveChanged = true; - m_dirWatcherTimer->start(); -} - -void MainWindow::handleDirWatcherTimer() -{ - if (m_bFilesHaveChanged) - { - m_bFilesHaveChanged = false; - if (m_bCleanRunning) - { - m_bUpdateFilesAfterClean = true; + QStringList paths; + if (allRows) { + for (int i = 0; i < m_filesTable->rowCount(); ++i) { + auto *item = m_filesTable->item(i, 0); + if (item) + paths << (m_sInDir + "/" + item->data(Qt::UserRole).toString()); + } + } else { + QSet rows; + for (auto *item : m_filesTable->selectedItems()) + rows.insert(item->row()); + for (int row : rows) { + auto *item = m_filesTable->item(row, 0); + if (item) + paths << (m_sInDir + "/" + item->data(Qt::UserRole).toString()); } - else - updateFileListing(); - } -} - -void MainWindow::updateFileListing() -{ - ui->filesTable->setRowCount(0); - auto pattern = ui->filePattern->text(); - QDir dir(ui->inDirectory->text()); - dir.setNameFilters((QStringList(pattern))); - dir.setFilter(QDir::Files | QDir::NoDotAndDotDot | QDir::Readable | QDir::CaseSensitive); - QStringList totalfiles = dir.entryList(); - ui->mdlsDetectedLabel->setText(tr("Files detected: ") % QString::number(totalfiles.count())); - if (!ui->decompileCheck->isChecked()) - ui->mdlsCleanedLabel->setText(tr("Files Cleaned: 0")); - else - ui->mdlsCleanedLabel->setText(tr("Files Decompiled: 0")); - ui->mdlsFailedLabel->setText(tr("Failures: 0")); - - for (const QString &filePath : totalfiles) - { - QFile inputFile(ui->inDirectory->text() % "/" % filePath); - QTextStream stream(&inputFile); - inputFile.open(QIODevice::ReadOnly); - if (!inputFile.isOpen()) - continue; - auto line = stream.readLine().trimmed().toStdString(); - inputFile.close(); - auto isASCII = std::all_of(line.begin(), line.end(), ::isprint); - auto *fileNameItem = new QTableWidgetItem(filePath); - fileNameItem->setIcon(isASCII ? m_iconASCIIMdl : m_iconBinaryMdl); - fileNameItem->setToolTip(isASCII ? tr("ASCII MDL") : tr("Binary MDL")); - auto *fileSizeItem = new QTableWidgetItem(); - fileSizeItem->setText(QString::number(inputFile.size())); - auto *fixesItem = new QTableWidgetItem("0"); - fixesItem->setTextAlignment(Qt::AlignCenter); - auto *timerItem = new QTableWidgetItem("00:00.000"); - timerItem->setTextAlignment(Qt::AlignCenter); - int row = ui->filesTable->rowCount(); - ui->filesTable->insertRow(row); - ui->filesTable->setItem(row, 0, fileNameItem); - ui->filesTable->setItem(row, 1, fileSizeItem); - ui->filesTable->setItem(row, 3, fixesItem); - ui->filesTable->setItem(row, 4, timerItem); } + return paths; } -void MainWindow::onUpdateInDir(const QString& newInDir) +void MainWindow::onReportIssueTriggered() { - m_dirWatcherTimer->stop(); - if (!m_sInDir.isEmpty()) - m_fsWatcher.removePath(m_sInDir); - m_bFilesHaveChanged = false; - m_fsWatcher.addPath(newInDir); - ui->inDirectory->setText(newInDir); - replaceUserOption("g_indir", newInDir, true); - m_sInDir = newInDir; - updateFileListing(); - m_dirWatcherTimer->start(); -} + QStringList filesToReport; -void MainWindow::on_indirButton_released() -{ - QFileDialog dialog(this); - QStringList inDirectory; - dialog.setFileMode(QFileDialog::Directory); - dialog.setOption(QFileDialog::ShowDirsOnly,true); - dialog.setDirectory(m_sInDir); - dialog.setLabelText(QFileDialog::Accept, tr("Set")); - if ( dialog.exec() ) - { - inDirectory = dialog.selectedFiles(); - onUpdateInDir(inDirectory.at(0)); + // Prefer last failed files, then selected table rows + if (!m_lastFailedFiles.isEmpty()) { + filesToReport = m_lastFailedFiles; + } else { + filesToReport = collectTableFilePaths(false); } -} -void MainWindow::on_inDirectory_textChanged(const QString &arg1) -{ - const QFileInfo inputDir(arg1); - QDir dir(QDir::currentPath()); - QString s, f; - s = dir.relativeFilePath(arg1); - f = dir.absoluteFilePath(arg1); - if ((!inputDir.exists()) || (!inputDir.isDir()) || (!inputDir.isWritable())) - { - if (QFile(s).exists()) - { - ui->inDirectory->setStatusTip(tr("Input folder resolved as ") %f); - ui->inDirectory->setStyleSheet(""); - } - else - ui->inDirectory->setStyleSheet("color: #FF0000"); - } - else - { - ui->inDirectory->setStatusTip(tr("Input folder resolved as ") %f); - ui->inDirectory->setStyleSheet(""); + if (filesToReport.isEmpty()) { + QMessageBox::information(this, "Report Issue", + "Select one or more files in the table first, or run cleanmodels so failed files can be reported."); + return; } -} -void MainWindow::on_inDirectory_editingFinished() -{ - const QFileInfo outputDir(ui->inDirectory->text()); - if ((!outputDir.exists()) || (!outputDir.isDir()) || (!outputDir.isWritable())) - { - ui->inDirectory->setStyleSheet("color: #FF0000"); - ui->cleanButton->setEnabled(false); - ui->cleanButton->setToolTip(tr("Action disabled until valid input directory set.")); - } - else - { - ui->inDirectory->setStyleSheet(""); - ui->cleanButton->setEnabled(true); - ui->cleanButton->setToolTip(tr("Perform action.")); - onUpdateInDir(ui->inDirectory->text()); + if (!m_lastErrorOutput.isEmpty()) { + reportIssue(filesToReport, m_lastErrorOutput, m_lastCommand); + } else { + reportIssueInteractive(filesToReport); } } -void MainWindow::on_outdirButton_released() +void MainWindow::reportIssue(const QStringList &files, const QString &errorOutput, const QString &command) { - QFileDialog dialog(this); - QStringList outDirectory; - dialog.setFileMode(QFileDialog::Directory); - dialog.setOption(QFileDialog::ShowDirsOnly,true); - dialog.setDirectory(m_sOutDir); - dialog.setLabelText(QFileDialog::Accept, tr("Set")); - if ( dialog.exec() ) - { - outDirectory = dialog.selectedFiles(); - ui->outDirectory->setText(outDirectory.at(0)); - m_sOutDir = ui->outDirectory->text(); - replaceUserOption("g_outdir", m_sOutDir, true); + if (m_sBinaryPath.isEmpty()) { + QMessageBox::warning(this, "Report Issue", + "Cannot find the cleanmodels CLI binary.\n" + "The report command requires the CLI to be available."); + return; } -} - -void MainWindow::on_outDirectory_textChanged(const QString &arg1) -{ - const QFileInfo outputDir(arg1); - QDir dir(QDir::currentPath()); - ui->outDirectory->setStatusTip(tr("Output folder resolved as ") % dir.absoluteFilePath(arg1)); -} -void MainWindow::on_outDirectory_editingFinished() -{ - m_sOutDir = ui->outDirectory->text(); - const QFileInfo outputDir(m_sOutDir); - QDir dir(QDir::currentPath()); - QString f = dir.absoluteFilePath(m_sOutDir); - replaceUserOption("g_outdir", m_sOutDir, true); - ui->outDirectory->setStatusTip(tr("Output folder resolved as ") % f); -} + QStringList args; + args << "report"; + args << "--error" << errorOutput; + if (!command.isEmpty()) + args << "--command" << command; -void MainWindow::on_filePattern_textChanged(const QString &pattern) -{ - m_dirWatcherTimer->stop(); - m_bFilesHaveChanged = false; - replaceUserOption("g_pattern", pattern, true); - updateFileListing(); - m_dirWatcherTimer->start(); -} - -void MainWindow::on_modelClassCombo_currentIndexChanged(int index) -{ - switch(index) - { - case 1: - replaceUserOption("classification","character"); - break; - case 2: - replaceUserOption("classification","door"); - break; - case 3: - replaceUserOption("classification","effect"); - break; - case 4: - replaceUserOption("classification","item"); - break; - case 5: - replaceUserOption("classification","tile"); - break; - case 0: - default: - replaceUserOption("classification","automatic"); - break; - } - if (!index || index == 5) - { - if (ui->mainTabs->count() == 1) - ui->mainTabs->addTab(ui->tilesTab, "Tiles"); + for (const auto &f : files) { + if (QFile::exists(f)) + args << f; } - else - { - if (ui->mainTabs->count() == 2) - ui->mainTabs->removeTab(1); - } - ui->rescaleFrame->setEnabled(index != 5); - ui->placeableWithTransparencyCheck->setEnabled(index <= 1); - if (index <= 1 && ui->placeableWithTransparencyCheck->isChecked()) - ui->transparentBitmapKeys->setEnabled(true); -} -void MainWindow::on_snapCombo_currentIndexChanged(int index) -{ - switch(index) - { - case 1: - replaceUserOption("snap","binary"); - break; - case 2: - replaceUserOption("snap","decimal"); - break; - case 3: - replaceUserOption("snap","fine"); - break; - case 0: - default: - replaceUserOption("snap","none"); - break; + if (args.count() <= 4) { + QMessageBox::warning(this, "Report Issue", + "None of the failed model files could be found on disk.\n" + "They may have been moved or deleted."); + return; } -} -void MainWindow::on_snapTVertsCombo_currentIndexChanged(int index) -{ - switch(index) - { - case 1: - replaceUserOption("tvert_snap","256"); - break; - case 2: - replaceUserOption("tvert_snap","512"); - break; - case 3: - replaceUserOption("tvert_snap","1024"); - break; - case 0: - default: - replaceUserOption("tvert_snap","no"); - break; - } -} + auto confirm = QMessageBox::question(this, "Report Issue", + QString("Submit a bug report with %1 file(s)?\n\n" + "This will send the model file(s) and error details to the cleanmodels issue tracker.\n" + "No GitHub account is needed.") + .arg(files.size()), + QMessageBox::Yes | QMessageBox::Cancel); -void MainWindow::on_renderShadowsCombo_currentIndexChanged(int index) -{ - switch(index) - { - case 1: - replaceUserOption("shadow","all"); - break; - case 2: - replaceUserOption("shadow","none"); - break; - case 0: - default: - replaceUserOption("shadow","default"); - break; - } -} - -void MainWindow::on_repivotCombo_currentIndexChanged(int index) -{ - switch(index) - { - case 1: - replaceUserOption("repivot","all"); - break; - case 2: - replaceUserOption("repivot","none"); - break; - case 0: - default: - replaceUserOption("repivot","if_needed"); - break; - } - ui->repivotBox->setEnabled(index <= 1); -} + if (confirm != QMessageBox::Yes) + return; -void MainWindow::on_allowSplittingCheck_toggled(bool checked) -{ - replaceUserOption("allow_split", checked ? "yes" : "no"); - ui->allowSplittingFrame->setEnabled(checked); -} + appendDebugHtml(LogHtml::span(LogColor::Info, QStringLiteral("Submitting bug report...")) % QStringLiteral("
    ")); + + auto *proc = new QProcess(this); + connect(proc, QOverload::of(&QProcess::finished), + this, [this, proc](int exitCode, QProcess::ExitStatus) { + QString output = QString::fromUtf8(proc->readAllStandardOutput()).trimmed(); + QString errors = QString::fromUtf8(proc->readAllStandardError()).trimmed(); + proc->deleteLater(); + + if (exitCode == 0 && !output.isEmpty()) { + QString issueUrl = output.split('\n').last().trimmed(); + appendDebugHtml(LogHtml::span(LogColor::Success, + QStringLiteral("Report submitted: ") % issueUrl.toHtmlEscaped() % QStringLiteral("")) + % QStringLiteral("
    ")); + m_detailPanel->setHtml( + LogHtml::detailLine(LogColor::FixApplied, QStringLiteral("Report submitted successfully!")) + % QStringLiteral("

    ") + % issueUrl.toHtmlEscaped() % QStringLiteral("

    ")); + } else { + QString msg = errors.isEmpty() ? "Unknown error" : errors; + appendDebugHtml(LogHtml::span(LogColor::Error, + QStringLiteral("Report failed: ") % msg.toHtmlEscaped()) % QStringLiteral("
    ")); + QMessageBox::warning(this, "Report Failed", + "Failed to submit the bug report:\n\n" + msg); + } + }); -void MainWindow::on_subObjectSpin_editingFinished() -{ - auto val = ui->subObjectSpin->cleanText(); - replaceUserOption("min_Size", val); + proc->start(m_sBinaryPath, args); } -void MainWindow::on_smoothingGroupsCombo_currentIndexChanged(int index) +void MainWindow::reportIssueInteractive(const QStringList &files) { - switch(index) - { - case 1: - replaceUserOption("use_Smoothed","ignore"); - break; - case 2: - replaceUserOption("use_Smoothed","protect"); - break; - case 0: - default: - replaceUserOption("use_Smoothed","use"); - break; + // Validate total size before prompting user + qint64 totalSize = 0; + QStringList validFiles; + for (const auto &f : files) { + QFileInfo fi(f); + if (!fi.exists()) continue; + if (fi.size() > ReportLimits::MaxFileSize) { + QMessageBox::warning(this, "Report Issue", + QString("File '%1' exceeds the 10MB per-file limit and cannot be reported.") + .arg(fi.fileName())); + return; + } + totalSize += fi.size(); + validFiles << f; } -} - -void MainWindow::on_splitFirstCombo_currentIndexChanged(int index) -{ - replaceUserOption("split_Priority", index ? "concave" : "convex"); -} -void MainWindow::on_pivotsBelowZeroZCombo_currentIndexChanged(int index) -{ - switch(index) - { - case 1: - replaceUserOption("'pivots_below_z=0'","allow"); - break; - case 2: - replaceUserOption("'pivots_below_z=0'","slice"); - break; - case 0: - default: - replaceUserOption("'pivots_below_z=0'","disallow"); - break; + if (validFiles.isEmpty()) { + QMessageBox::warning(this, "Report Issue", + "None of the selected files could be found on disk."); + return; } -} -void MainWindow::on_moveBadPivotsCombo_currentIndexChanged(int index) -{ - switch(index) - { - case 1: - replaceUserOption("move_bad_pivots","top"); - break; - case 2: - replaceUserOption("move_bad_pivots","middle"); - break; - case 3: - replaceUserOption("move_bad_pivots","bottom"); - break; - case 0: - default: - replaceUserOption("move_bad_pivots","no"); - break; + if (totalSize > ReportLimits::MaxTotalSize) { + QMessageBox::warning(this, "Report Issue", + QString("Total size of selected files (%1) exceeds the 25MB limit.\n" + "Try selecting fewer files.") + .arg(humanFileSize(totalSize))); + return; } -} - -void MainWindow::on_forceWhiteCheck_toggled(bool checked) -{ - replaceUserOption("force_white", checked ? "yes" : "no"); -} -void MainWindow::on_repairAABBCombo_currentIndexChanged(int index) -{ - switch(index) - { - case 1: - replaceUserOption("fix_overhangs","no"); - break; - case 2: - replaceUserOption("fix_overhangs","interior_only"); - break; - case 0: - default: - replaceUserOption("fix_overhangs","yes"); - break; - } -} + bool ok = false; + QString description = QInputDialog::getMultiLineText(this, "Report Issue", + QString("Describe the problem with %1 file(s) (%2 total):\n\n" + "Examples: \"missing faces after decompile\", \"textures wrong after compile\",\n" + "\"model looks broken in-game\"\n\n" + "Leave blank if you just want us to look at the file(s).") + .arg(validFiles.size()) + .arg(humanFileSize(totalSize)), + QString(), &ok); + + if (!ok) + return; -void MainWindow::on_changeWokMatCheck_toggled(bool checked) -{ - replaceUserOption("map_aabb_material", checked ? "yes" : "no"); - ui->changeWokMatGroupBox->setEnabled(checked); -} + if (description.isEmpty()) + description = "User-reported issue (no specific error โ€” model may produce incorrect output)"; -void MainWindow::on_raiseLowerCombo_currentIndexChanged(int index) -{ - switch(index) - { - case 1: - replaceUserOption("tile_raise","raise"); - break; - case 2: - replaceUserOption("tile_raise","lower"); - break; - case 0: - default: - replaceUserOption("tile_raise","no"); - break; - } - ui->raiseLowerAmountSpin->setEnabled(index >= 1); + reportIssue(validFiles, description, m_lastCommand); } -void MainWindow::on_raiseLowerAmountSpin_editingFinished() +void MainWindow::populateCheckTree(const QJsonArray &checks) { - auto val = ui->raiseLowerAmountSpin->cleanText(); - replaceUserOption("tile_raise_amount", val); -} + // Clear any previous dynamic content + m_categoryChecks.clear(); + m_categoryWidgets.clear(); + m_individualChecks.clear(); -void MainWindow::on_sliceForTileFadeCombo_currentIndexChanged(int index) -{ - switch(index) - { - case 1: - replaceUserOption("slice","no"); - break; - case 2: - replaceUserOption("slice","undo"); - break; - case 0: - default: - replaceUserOption("slice","yes"); - break; + QLayoutItem *child; + while ((child = m_checksDetailWidget->layout()->takeAt(0)) != nullptr) { + delete child->widget(); + delete child; } - ui->sliceHeightFrame->setEnabled(index == 0); -} - -void MainWindow::on_foliageCombo_currentIndexChanged(int index) -{ - switch(index) - { - case 1: - replaceUserOption("foliage","tilefade"); - break; - case 2: - replaceUserOption("foliage","animate"); - break; - case 3: - replaceUserOption("foliage","de-animate"); - break; - case 4: - replaceUserOption("foliage","ignore"); - break; - case 0: - default: - replaceUserOption("foliage","no_change"); - break; + // Group checks by category preserving insertion order + QStringList categoryOrder; + QMap byCategory; + for (const auto &val : checks) { + QJsonObject obj = val.toObject(); + QString cat = obj["category"].toString(); + if (!byCategory.contains(cat)) + categoryOrder << cat; + byCategory[cat].append(obj); } - ui->foliageBitmapKeys->setEnabled(index != 4); - ui->foliageBitmapKeysLabel->setEnabled(index != 4); -} + auto *layout = qobject_cast(m_checksDetailWidget->layout()); + + for (const QString &cat : categoryOrder) { + const QJsonArray &catChecks = byCategory[cat]; + QString label = cat.at(0).toUpper() + cat.mid(1) + + QString(" (%1 checks)").arg(catChecks.size()); + + auto *catCheck = new QCheckBox(label); + catCheck->setChecked(true); + catCheck->setToolTip(QString("Enable/disable all %1 checks").arg(cat)); + QFont f = catCheck->font(); + f.setBold(true); + catCheck->setFont(f); + layout->addWidget(catCheck); + m_categoryChecks[cat] = catCheck; + + auto *catContent = new QWidget; + auto *catLayout = new QVBoxLayout(catContent); + catLayout->setContentsMargins(Layout::IndentLeft, 0, 0, 0); + catLayout->setSpacing(Layout::TightSpacing); + catContent->setVisible(false); + layout->addWidget(catContent); + m_categoryWidgets[cat] = catContent; + + for (const auto &c : catChecks) { + QJsonObject obj = c.toObject(); + QString name = obj["name"].toString(); + QString desc = obj["description"].toString(); + bool fixable = obj["fixable"].toBool(); + + QString checkLabel = name; + if (!desc.isEmpty()) + checkLabel += QString::fromUtf8(" \xe2\x80\x94 ") + desc; + + auto *cb = new QCheckBox(checkLabel); + cb->setChecked(true); + cb->setProperty("checkName", name); + cb->setProperty("checkCategory", cat); + QString tip = desc; + if (fixable) + tip += " (auto-fixable)"; + cb->setToolTip(tip); + catLayout->addWidget(cb); + m_individualChecks[name] = cb; + } -void MainWindow::on_groundRotateTextureCombo_currentIndexChanged(int index) -{ - switch(index) - { - case 1: - replaceUserOption("rotate_ground","1"); - break; - case 2: - replaceUserOption("rotate_ground","0"); - break; - case 0: - default: - replaceUserOption("rotate_ground","no_change"); - break; + // Category unchecked โ†’ show individual checks + connect(catCheck, &QCheckBox::toggled, this, [catContent, catCheck, cat, this](bool allOn) { + catContent->setVisible(!allOn); + if (allOn) { + for (auto it = m_individualChecks.begin(); it != m_individualChecks.end(); ++it) { + if (it.value()->property("checkCategory").toString() == cat) + it.value()->setChecked(true); + } + } + }); } - bool showGroundTextEdit = ui->groundRotateTextureCombo->currentIndex() || - ui->retileGroundPlanesCombo->currentIndex() || - ui->tileEdgeChamfersCombo->currentIndex(); - ui->groundBitmapKeys->setEnabled(showGroundTextEdit); - ui->groundBitmapKeysLabel->setEnabled(showGroundTextEdit); } -void MainWindow::on_tileEdgeChamfersCombo_currentIndexChanged(int index) +QStringList MainWindow::selectedCheckNames() const { - switch(index) - { - case 1: - replaceUserOption("chamfer","add"); - break; - case 2: - replaceUserOption("chamfer","delete"); - break; - case 0: - default: - replaceUserOption("chamfer","no_change"); - break; + QStringList selected; + for (auto it = m_individualChecks.constBegin(); it != m_individualChecks.constEnd(); ++it) { + QString cat = it.value()->property("checkCategory").toString(); + auto catIt = m_categoryChecks.find(cat); + if (catIt != m_categoryChecks.end() && catIt.value()->isChecked()) { + selected << it.key(); + } else if (it.value()->isChecked()) { + selected << it.key(); + } } - bool showGroundTextEdit = ui->groundRotateTextureCombo->currentIndex() || - ui->retileGroundPlanesCombo->currentIndex() || - ui->tileEdgeChamfersCombo->currentIndex(); - ui->groundBitmapKeys->setEnabled(showGroundTextEdit); - ui->groundBitmapKeysLabel->setEnabled(showGroundTextEdit); + return selected; } -void MainWindow::on_retileGroundPlanesCombo_currentIndexChanged(int index) +int MainWindow::totalCheckCount() const { - switch(index) - { - case 1: - replaceUserOption("tile_ground","1"); - break; - case 2: - replaceUserOption("tile_ground","2"); - break; - case 3: - replaceUserOption("tile_ground","3"); - break; - case 0: - default: - replaceUserOption("tile_ground","no_change"); - break; - } - bool showGroundTextEdit = ui->groundRotateTextureCombo->currentIndex() || - ui->retileGroundPlanesCombo->currentIndex() || - ui->tileEdgeChamfersCombo->currentIndex(); - ui->groundBitmapKeys->setEnabled(showGroundTextEdit); - ui->groundBitmapKeysLabel->setEnabled(showGroundTextEdit); + return m_individualChecks.size(); } -void MainWindow::on_meshMergeCheck_toggled(bool checked) +void MainWindow::onQuitTriggered() { - replaceUserOption("merge_by_bitmap", checked ? "yes" : "no"); + if (m_bCleanRunning) m_pCleanProcess->kill(); + QApplication::quit(); } -void MainWindow::on_placeableWithTransparencyCheck_toggled(bool checked) +// --------------------------------------------------------------------------- +// Directory / file listing +// --------------------------------------------------------------------------- +void MainWindow::onUpdateInDir(const QString &newInDir) { - if (checked) - { - replaceUserOption("placeable_with_transparency","yes"); - if (ui->modelClassCombo->currentIndex() <= 1) - ui->transBitmapKeyFrame->setEnabled(true); - } - else - { - replaceUserOption("placeable_with_transparency","no"); - ui->transBitmapKeyFrame->setEnabled(false); - } + m_dirWatcherTimer->stop(); + if (!m_sInDir.isEmpty()) m_fsWatcher.removePath(m_sInDir); + m_bFilesHaveChanged = false; + m_fsWatcher.addPath(newInDir); + m_inDirectory->setText(newInDir); + m_inDirectory->setStyleSheet(""); + m_sInDir = newInDir; + m_cleanButton->setEnabled(true); + updateFileListing(); + m_dirWatcherTimer->start(); + populateRefModelCombo(); } -void MainWindow::on_animateSplotchesCheck_toggled(bool checked) +void MainWindow::populateRefModelCombo() { - if (checked) - { - replaceUserOption("splotch","animate"); - ui->splotchBitmapKeysLabel->setEnabled(true); - ui->splotchBitmapKeys->setEnabled(true); + QString current = m_refModelCombo->currentData().toString(); + m_refModelCombo->clear(); + m_refModelCombo->addItem("(none)"); + if (!m_sInDir.isEmpty()) { + QDir dir(m_sInDir); + dir.setNameFilters({"*.mdl"}); + dir.setFilter(QDir::Files | QDir::Readable); + for (const QString &f : dir.entryList()) + m_refModelCombo->addItem(f, dir.absoluteFilePath(f)); } - else - { - replaceUserOption("splotch","ignore"); - ui->splotchBitmapKeysLabel->setEnabled(false); - ui->splotchBitmapKeys->setEnabled(false); + if (!current.isEmpty()) { + int idx = m_refModelCombo->findData(current); + if (idx >= 0) m_refModelCombo->setCurrentIndex(idx); } } -void MainWindow::on_transparentBitmapKeys_editingFinished() -{ - replaceUserOption("transparency_key",ui->transparentBitmapKeys->text().toStdString().c_str()); -} - -void MainWindow::on_cullInvisibleCheck_toggled(bool checked) +void MainWindow::onDirectoryContentsChanged() { - replaceUserOption("invisible_mesh_cull", checked ? "yes" : "no"); + m_dirWatcherTimer->stop(); + m_bFilesHaveChanged = true; + m_dirWatcherTimer->start(); } -void MainWindow::on_renderTrimeshCombo_currentIndexChanged(int index) +void MainWindow::handleDirWatcherTimer() { - switch(index) + if (m_bFilesHaveChanged) { - case 1: - replaceUserOption("render","all"); - break; - case 2: - replaceUserOption("render","none"); - break; - case 0: - default: - replaceUserOption("render","default"); - break; + m_bFilesHaveChanged = false; + if (m_bCleanRunning) + m_bUpdateFilesAfterClean = true; + else + updateFileListing(); } } -void MainWindow::on_changeWokMatFromSpin_editingFinished() -{ - auto val = ui->changeWokMatFromSpin->cleanText(); - replaceUserOption("map_aabb_from", val); -} - -void MainWindow::on_changeWokMatToSpin_editingFinished() -{ - auto val = ui->changeWokMatToSpin->cleanText(); - replaceUserOption("map_aabb_to", val); -} - -void MainWindow::on_waterFixupsCheck_toggled(bool checked) +QString MainWindow::humanFileSize(qint64 bytes) { - ui->waterFrame->setEnabled(checked); + if (bytes < 1024) + return QString::number(bytes) + " B"; + if (bytes < 1024 * 1024) + return QString::number(bytes / 1024.0, 'f', 1) + " KB"; + return QString::number(bytes / (1024.0 * 1024.0), 'f', 1) + " MB"; } -void MainWindow::on_waterBitmapKeys_editingFinished() -{ - replaceUserOption("water_key", ui->waterBitmapKeys->text()); -} - -void MainWindow::on_foliageBitmapKeys_editingFinished() -{ - replaceUserOption("foliage_key", ui->foliageBitmapKeys->text()); -} - -void MainWindow::on_splotchBitmapKeys_editingFinished() -{ - replaceUserOption("splotch_key", ui->splotchBitmapKeys->text()); -} - -void MainWindow::on_groundBitmapKeys_editingFinished() -{ - replaceUserOption("ground_key", ui->groundBitmapKeys->text()); -} - -void MainWindow::on_dynamicWaterCombo_currentIndexChanged(int index) -{ - switch(index) - { - case 1: - replaceUserOption("dynamic_water","no"); - break; - case 2: - replaceUserOption("dynamic_water","wavy"); - break; - case 0: - default: - replaceUserOption("dynamic_water","yes"); - break; - } - ui->waveHeightFrame->setEnabled(index == 2); - ui->retileWaterCombo->setEnabled(index != 2); - ui->retileWaterLabel->setEnabled(index != 2); -} - -void MainWindow::on_waveHeightSpin_editingFinished() +void MainWindow::updateFileListing() { - auto val = ui->waveHeightSpin->cleanText(); - replaceUserOption("wave_height", val); - -} + m_filesTable->setRowCount(0); + QDir dir(m_inDirectory->text()); + dir.setNameFilters(QStringList(m_filePattern->text())); + dir.setFilter(QDir::Files | QDir::NoDotAndDotDot | QDir::Readable | QDir::CaseSensitive); + QStringList totalfiles = dir.entryList(); + m_mdlsDetectedLabel->setText("Detected: " + QString::number(totalfiles.count())); + m_mdlsCleanedLabel->setText( + m_radioCompile->isChecked() ? "Compiled: 0" : + m_radioDecompile->isChecked() ? "Decompiled: 0" : "Cleaned: 0"); + m_mdlsFailedLabel->setText("Failed: 0"); -void MainWindow::on_waterRotateTextureCombo_currentIndexChanged(int index) -{ - switch(index) + for (const QString &filePath : totalfiles) { - case 1: - replaceUserOption("rotate_water","1"); - break; - case 2: - replaceUserOption("rotate_water","0"); - break; - case 0: - default: - replaceUserOption("rotate_water","no_change"); - break; + QString fullPath = m_inDirectory->text() + "/" + filePath; + QFile inputFile(fullPath); + if (!inputFile.open(QIODevice::ReadOnly)) continue; + inputFile.close(); + bool isASCII = !ModelViewport::fileIsBinaryMdl(fullPath); + auto *fileNameItem = new QTableWidgetItem(); + fileNameItem->setText((isASCII ? QString::fromUtf8("๐Ÿ“„ ") : QString::fromUtf8("๐Ÿ“ฆ ")) + filePath); + fileNameItem->setData(Qt::UserRole, filePath); + fileNameItem->setToolTip(isASCII ? "ASCII MDL" : "Binary MDL"); + auto *fileSizeItem = new QTableWidgetItem(humanFileSize(inputFile.size())); + fileSizeItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); + fileSizeItem->setData(Qt::UserRole, inputFile.size()); + auto *statusItem = new QTableWidgetItem("Pending"); + statusItem->setTextAlignment(Qt::AlignCenter); + auto *fixesItem = new QTableWidgetItem(); + fixesItem->setTextAlignment(Qt::AlignCenter); + auto *timerItem = new QTableWidgetItem(); + timerItem->setTextAlignment(Qt::AlignCenter); + int row = m_filesTable->rowCount(); + m_filesTable->insertRow(row); + m_filesTable->setItem(row, 0, fileNameItem); + m_filesTable->setItem(row, 1, fileSizeItem); + m_filesTable->setItem(row, 2, statusItem); + m_filesTable->setItem(row, 3, fixesItem); + m_filesTable->setItem(row, 4, timerItem); } } -void MainWindow::on_retileWaterCombo_currentIndexChanged(int index) +void MainWindow::copyToClipboard() { - switch(index) - { - case 1: - replaceUserOption("tile_water","1"); - break; - case 2: - replaceUserOption("tile_water","2"); - break; - case 3: - replaceUserOption("tile_water","3"); - break; - case 0: - default: - replaceUserOption("tile_water","no_change"); - break; - } + if (m_filesTable->selectedItems().isEmpty()) return; + auto *selectedFile = m_filesTable->item(m_filesTable->currentRow(), 0); + QApplication::clipboard()->setText(m_sInDir + "/" + selectedFile->data(Qt::UserRole).toString()); } -void MainWindow::on_filesTable_customContextMenuRequested(const QPoint &pos) +// --------------------------------------------------------------------------- +// Sidebar / raw-log / detail panel +// --------------------------------------------------------------------------- +void MainWindow::toggleSidebar() { - // Handle global position - QPoint globalPos = ui->filesTable->mapToGlobal(pos); - - // Create menu and insert some actions - QMenu myMenu; - myMenu.addAction(tr("Copy path to clipboard"), this, SLOT(copyToClipboard())); - myMenu.exec(globalPos); + bool visible = m_sidebarWidget->isVisible(); + m_sidebarWidget->setVisible(!visible); + m_sidebarToggleBtn->setText(visible ? tr("Show Sidebar") : tr("Hide Sidebar")); + m_sidebarToggleBtn->setVisible(visible); } -void MainWindow::on_filesTable_doubleClicked(const QModelIndex &index) +void MainWindow::toggleRawLog() { -#if (QT_VERSION >= QT_VERSION_CHECK(5, 11, 0)) - QString cellText = index.siblingAtColumn(0).data().toString(); - ui->debugTextBrowser->moveCursor(QTextCursor::Start); - if (!ui->debugTextBrowser->find(cellText)) - ui->debugTextBrowser->moveCursor(QTextCursor::End); -#endif + m_rawLogVisible = !m_rawLogVisible; + m_rawLogDrawer->setVisible(m_rawLogVisible); } -void MainWindow::setRescaleOption() +void MainWindow::showFileDetails(const QString &fileName) { - QString rescaleXYZ = "no"; - if (ui->rescaleXSpin->value() != 1 || ui->rescaleYSpin->value() != 1 || ui->rescaleZSpin->value() != 1) - { - rescaleXYZ = "[" % QString::number(ui->rescaleXSpin->value(),'g',4) % "," % - QString::number(ui->rescaleYSpin->value(),'g',4) % "," % - QString::number(ui->rescaleZSpin->value(),'g',4) % "]"; + if (m_fileResults.contains(fileName)) { + m_detailPanel->setHtml(m_fileResults[fileName].join("")); + } else { + m_detailPanel->setHtml(LogHtml::detailLine(LogColor::Placeholder, + QStringLiteral("No results yet for this file."))); } - replaceUserOption("rescaleXYZ",rescaleXYZ); } -void MainWindow::on_rescaleLockBtn_clicked(bool checked) +void MainWindow::showBatchSummary() { - if (checked) - { - ui->rescaleLockBtn->setIcon(m_iconLockRescaleBtn); + if (m_batchSummary.isEmpty()) { + m_detailPanel->setHtml(""); + return; } - else - ui->rescaleLockBtn->setIcon(m_iconUnlockRescaleBtn); - - ui->rescaleYSpin->setValue(ui->rescaleXSpin->value()); - ui->rescaleZSpin->setValue(ui->rescaleXSpin->value()); - ui->rescaleYSpin->setEnabled(!checked); - ui->rescaleZSpin->setEnabled(!checked); + m_detailPanel->setHtml("

    " + m_batchSummary.toHtmlEscaped() + "

    "); } -void MainWindow::on_rescaleXSpin_valueChanged(double arg1) +void MainWindow::dragEnterEvent(QDragEnterEvent *event) { - if (ui->rescaleLockBtn->isChecked()) - { - QSignalBlocker blockY(ui->rescaleYSpin); - QSignalBlocker blockZ(ui->rescaleZSpin); - ui->rescaleYSpin->setValue(arg1); - ui->rescaleZSpin->setValue(arg1); + if (event->mimeData()->hasUrls()) { + for (const QUrl &url : event->mimeData()->urls()) { + if (url.isLocalFile() && QFileInfo(url.toLocalFile()).isDir()) { + event->acceptProposedAction(); + return; + } + } } - setRescaleOption(); } -void MainWindow::on_rescaleYSpin_valueChanged(double) +void MainWindow::dropEvent(QDropEvent *event) { - setRescaleOption(); -} - -void MainWindow::on_rescaleZSpin_valueChanged(double) -{ - setRescaleOption(); + for (const QUrl &url : event->mimeData()->urls()) { + QString path = url.toLocalFile(); + if (QFileInfo(path).isDir()) { + onUpdateInDir(path); + return; + } + } } diff --git a/mainwindow.h b/mainwindow.h index 9c7910f..b4ede2a 100644 --- a/mainwindow.h +++ b/mainwindow.h @@ -1,17 +1,31 @@ #ifndef MAINWINDOW_H #define MAINWINDOW_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 class FileSystemModel; +class ModelViewport; namespace Ui { class MainWindow; @@ -27,12 +41,10 @@ class MainWindow : public QMainWindow protected: void closeEvent(QCloseEvent *event) override; + void dragEnterEvent(QDragEnterEvent *event) override; + void dropEvent(QDropEvent *event) override; private slots: - void on_indirButton_released(); - void on_outdirButton_released(); - void on_cleanButton_released(); - void on_decompileCheck_stateChanged(int arg1); void onHelpTriggered(); void onSaveConfigTriggered(); void onLoadConfigTriggered(); @@ -41,103 +53,190 @@ private slots: void handleDirWatcherTimer(); void onDirectoryContentsChanged(); void updateFileListing(); - void on_cullInvisibleCheck_toggled(bool checked); - void on_meshMergeCheck_toggled(bool checked); - void on_forceWhiteCheck_toggled(bool checked); - void on_placeableWithTransparencyCheck_toggled(bool checked); - void on_animateSplotchesCheck_toggled(bool checked); - void on_transparentBitmapKeys_editingFinished(); - void on_allowSplittingCheck_toggled(bool checked); - void on_subObjectSpin_editingFinished(); - void on_modelClassCombo_currentIndexChanged(int index); - void on_foliageCombo_currentIndexChanged(int index); - void on_groundRotateTextureCombo_currentIndexChanged(int index); - void on_tileEdgeChamfersCombo_currentIndexChanged(int index); - void on_retileGroundPlanesCombo_currentIndexChanged(int index); - void on_snapCombo_currentIndexChanged(int index); - void on_snapTVertsCombo_currentIndexChanged(int index); - void on_inDirectory_textChanged(const QString &arg1); - void on_inDirectory_editingFinished(); - void on_outDirectory_editingFinished(); - void on_smoothingGroupsCombo_currentIndexChanged(int index); - void on_splitFirstCombo_currentIndexChanged(int index); - void on_repairAABBCombo_currentIndexChanged(int index); - void on_raiseLowerCombo_currentIndexChanged(int index); - void on_raiseLowerAmountSpin_editingFinished(); - void on_sliceForTileFadeCombo_currentIndexChanged(int index); - void on_changeWokMatCheck_toggled(bool checked); - void on_changeWokMatFromSpin_editingFinished(); - void on_changeWokMatToSpin_editingFinished(); - void on_moveBadPivotsCombo_currentIndexChanged(int index); - void on_pivotsBelowZeroZCombo_currentIndexChanged(int index); - void on_renderTrimeshCombo_currentIndexChanged(int index); - void on_renderShadowsCombo_currentIndexChanged(int index); - void on_repivotCombo_currentIndexChanged(int index); - void on_waterFixupsCheck_toggled(bool checked); - void on_waterBitmapKeys_editingFinished(); - void on_foliageBitmapKeys_editingFinished(); - void on_splotchBitmapKeys_editingFinished(); - void on_groundBitmapKeys_editingFinished(); - void on_dynamicWaterCombo_currentIndexChanged(int index); - void on_waveHeightSpin_editingFinished(); - void on_waterRotateTextureCombo_currentIndexChanged(int index); - void on_retileWaterCombo_currentIndexChanged(int index); - void on_outDirectory_textChanged(const QString &arg1); - void on_filePattern_textChanged(const QString &arg1); - void on_filesTable_customContextMenuRequested(const QPoint &pos); - void on_filesTable_doubleClicked(const QModelIndex &index); - void on_rescaleLockBtn_clicked(bool checked); - void on_rescaleXSpin_valueChanged(double arg1); - void on_rescaleYSpin_valueChanged(double arg1); - void on_rescaleZSpin_valueChanged(double arg1); - void onCaptureCleanModelsOutput(); + void onCleanStarted(); + void onCleanProcessError(QProcess::ProcessError err); void onCleanFinished(int, QProcess::ExitStatus); void copyToClipboard(); + void onReportIssueTriggered(); private: Ui::MainWindow *ui; + + // --- I/O widgets --- + QLineEdit *m_inDirectory; + QLineEdit *m_outDirectory; + QPushButton *m_indirButton; + QPushButton *m_outdirButton; + QLineEdit *m_filePattern; + QComboBox *m_classificationCombo; + + // --- Mode --- + QRadioButton *m_radioClean; + QRadioButton *m_radioDecompile; + QRadioButton *m_radioCompile; + + // --- Fixes --- + QCheckBox *m_allFixesCheck; + QWidget *m_fixesDetailWidget; + QCheckBox *m_checkValidateAll; + QWidget *m_checksDetailWidget; + QMap m_categoryChecks; + QMap m_categoryWidgets; + QMap m_individualChecks; + QCheckBox *m_checkStripDegen; + QCheckBox *m_checkFixAnims; + QCheckBox *m_checkRepairPivots; + QCheckBox *m_checkFixTilefade; + QCheckBox *m_checkTilefadeUndo; + QCheckBox *m_checkRebuildAABB; + QCheckBox *m_checkReparentChildren; + QCheckBox *m_checkWrapRoot; + QCheckBox *m_checkSplitMultiEdge; + + // --- Advanced section --- + QWidget *m_advancedGroup; + QDoubleSpinBox *m_scaleXSpin; + QDoubleSpinBox *m_scaleYSpin; + QDoubleSpinBox *m_scaleZSpin; + QPushButton *m_scaleLockBtn; + QComboBox *m_snapCombo; + QComboBox *m_tvertSnapCombo; + QComboBox *m_renderCombo; + QComboBox *m_shadowCombo; + QCheckBox *m_forceWhiteCheck; + QCheckBox *m_mergeByBitmapCheck; + QCheckBox *m_cullInvisibleCheck; + + // --- Tile section --- + QWidget *m_tileGroup; + QDoubleSpinBox *m_sliceHeightSpin; + QCheckBox *m_waterEnableCheck; + QComboBox *m_dynamicWaterCombo; + QDoubleSpinBox *m_waveHeightSpin; + QLineEdit *m_waterKeyEdit; + QComboBox *m_rotateWaterCombo; + QComboBox *m_retileWaterCombo; + QComboBox *m_foliageCombo; + QLineEdit *m_foliageKeyEdit; + QCheckBox *m_animateSplotchesCheck; + QLineEdit *m_splotchKeyEdit; + QComboBox *m_rotateGroundCombo; + QComboBox *m_chamferCombo; + QComboBox *m_retileGroundCombo; + QLineEdit *m_groundKeyEdit; + QComboBox *m_raiseLowerCombo; + QDoubleSpinBox *m_raiseAmountSpin; + QCheckBox *m_remapWokMatCheck; + QSpinBox *m_wokMatFromSpin; + QSpinBox *m_wokMatToSpin; + + // --- Pivot section --- + QWidget *m_pivotGroup; + QCheckBox *m_pivotAllowSplitCheck; + QComboBox *m_pivotBelowZ0Combo; + QComboBox *m_pivotMoveBadCombo; + QComboBox *m_pivotSmoothingCombo; + QSpinBox *m_pivotMinFacesSpin; + QComboBox *m_pivotSplitFirstCombo; + + // --- EE cleanup --- + QCheckBox *m_standardizeTexture0Check; + QCheckBox *m_stripEEExtrasCheck; + + // --- Placeable transparency --- + QCheckBox *m_placeableTransCheck; + QLineEdit *m_transparencyKeyEdit; + + // --- Viewport controls --- + QCheckBox *m_wireframeCheck; + QCheckBox *m_gridCheck; + QCheckBox *m_refModelCheck; + QComboBox *m_refModelCombo; + QPushButton *m_refBrowseBtn; + QLabel *m_animLabel = nullptr; + QComboBox *m_animCombo = nullptr; + + // --- Layout structure --- + QWidget *m_sidebarWidget = nullptr; + QSplitter *m_mainSplitter = nullptr; + QTextBrowser *m_detailPanel = nullptr; + QPushButton *m_sidebarToggleBtn = nullptr; + QSplitter *m_tableDetailSplitter = nullptr; + + // --- Action / table / log --- + QPushButton *m_cleanButton; + QTableWidget *m_filesTable; + QTextBrowser *m_debugTextBrowser; + QLabel *m_mdlsDetectedLabel; + QLabel *m_mdlsCleanedLabel; + QLabel *m_mdlsFailedLabel; + + // --- Raw log drawer --- + QWidget *m_rawLogDrawer = nullptr; + bool m_rawLogVisible = false; + QString m_batchSummary; + + // --- Per-file detail results --- + QMap m_fileResults; + + // --- Infrastructure --- FileSystemModel *m_pFileSystemModel = nullptr; QCompleter *m_pDirCompleter = nullptr; - QLabel* m_pCleanStatus; - QProcess* m_pCleanProcess; - QProgressBar* m_pStatusProgress; + QLabel *m_pCleanStatus; + QProcess *m_pCleanProcess; + QProgressBar *m_pStatusProgress; QString m_sBinaryName; QString m_sBinaryPath; QString m_sCurrentModel; QString m_sInDir; QString m_sOutDir; - QString m_sLastDirsPath; - QIcon m_iconReadingMDL; - QIcon m_iconDecompilingMDL; - QIcon m_iconCleaningMDL; - QIcon m_iconCleanError; - QIcon m_iconCleanSuccess; QIcon m_iconCleanButton; QIcon m_iconAbortButton; QIcon m_iconDecompileButton; - QIcon m_iconASCIIMdl; - QIcon m_iconBinaryMdl; - QIcon m_iconLockRescaleBtn; - QIcon m_iconUnlockRescaleBtn; QElapsedTimer m_cleanTimer; QFileSystemWatcher m_fsWatcher; QTimer *m_dirWatcherTimer; - bool m_bFilesHaveChanged; - bool m_bUpdateFilesAfterClean; - bool m_bCleanRunning; + QByteArray m_stdoutBuffer; + bool m_bFilesHaveChanged = false; + bool m_bUpdateFilesAfterClean = false; + bool m_bCleanRunning = false; int m_nMdlsCleaned = 0; int m_nMdlsFailed = 0; - void onUpdateInDir(const QString& newInDir); - void setRescaleOption(); - void replaceUserOption(const QString& str, const QString& rpl, bool coreValue = false); - void readInLastDirs(const QString& fileLoc); + ModelViewport *m_viewport = nullptr; + + // --- Report issue state --- + QStringList m_lastFailedFiles; + QString m_lastErrorOutput; + QString m_lastCommand; + + // --- Methods --- + void buildUi(); + void updateModeUI(); + static QFormLayout *makeStandardForm(int spacing = 4); + QWidget *createCollapsibleGroup(const QString &title, QWidget **contentOut, bool startCollapsed = true); + void onUpdateInDir(const QString &newInDir); + void populateRefModelCombo(); void readSettings(); void writeSettings(); - + void saveSettings(); + void loadSettings(); void doClean(); - int findModelRow(const QString& mdlFile); + QStringList buildCliArgs(); + int findModelRow(const QString &mdlFile); + void appendDebugHtml(const QString &html); + void toggleSidebar(); + void toggleRawLog(); + void showFileDetails(const QString &fileName); + void showBatchSummary(); + QStringList collectTableFilePaths(bool allRows) const; + void reportIssue(const QStringList &files, const QString &errorOutput, const QString &command); + void reportIssueInteractive(const QStringList &files); + void populateCheckTree(const QJsonArray &checks); + QStringList selectedCheckNames() const; + int totalCheckCount() const; + static QString humanFileSize(qint64 bytes); }; #endif // MAINWINDOW_H diff --git a/mainwindow.ui b/mainwindow.ui index 9411db9..8510736 100644 --- a/mainwindow.ui +++ b/mainwindow.ui @@ -3,2791 +3,82 @@ MainWindow - - 0 - 0 - 880 - 632 - - - - - 0 - 0 - + 001024700 - - 880 - 0 - - - - - 16777215 - 16777215 - + 800500 Clean Models:EE QT - - Qt::ToolButtonIconOnly - - - - - 0 - 0 - - - - - - - 6 - - - - - - 0 - 0 - - - - - 858 - 328 - - - - - 16777215 - 328 - - - - QTabWidget::West - - - QTabWidget::Rounded - - - 0 - - - - - 0 - 0 - - - - - 858 - 324 - - - - General - - - General settings. - - - - - 260 - 40 - 41 - 28 - - - - - - - - .. - - - - - - 260 - 10 - 41 - 28 - - - - - - - - .. - - - - - - 310 - 10 - 220 - 304 - - - - - 0 - 0 - - - - QFrame::StyledPanel - - - - - 9 - 250 - 201 - 51 - - - - QFrame::NoFrame - - - QFrame::Raised - - - - - 2 - -4 - 181 - 20 - - - - Transparent Bitmap Key(s) - - - - - true - - - - 0 - 18 - 201 - 28 - - - - <html><head/><body><p>Separate multiple keys by spaces</p></body></html> - - - -1 - - - 512 - - - - - - - 8 - 4 - 208 - 24 - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - Sets all ambient and diffuse values to 1 1 1 - - - Ambient/Diffuse to 1 - - - - - - 8 - 34 - 208 - 24 - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - <html><head/><body><p>If enabled, trimesh nodes with the same bitmap will be merged into a single node. It applies to all classes of model but will be skipped if the model has a non NULL supermodel. Meshes must be set to shadow 0 to be merged but if the โ€œshadow noneโ€ option is set, that will be applied before merging is attempted. The resulting trimesh node will be parented directly to the Aurora base at position and orientation zero and have the same name as one of the original ones. Mesh repairs, vertex welding and so on are applied after merging, in the normal way. If one mesh has vertexcolors and another has not, the missing vertexcolors will be padded with white (1 1 1).</p><p>The following will <span style=" font-weight:600;">not</span> be merged together:</p><ul style="margin-top: 0px; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; -qt-list-indent: 1;"><li style=" margin-top:12px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">meshes with incompatible parameters (render, alpha, ambient, diffuse, rotatetexture, ...)</li><li style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">meshes with animation keys or animated parents</li><li style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">meshes with child nodes</li><li style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">meshes with a scale other than unity or any of whose ancestors has a scale other than unity</li><li style=" margin-top:0px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">in the case of tile models, meshes descending from the 'a' node.</li></ul></body></html> - - - MeshMerge by bitmap - - - - - - 8 - 64 - 208 - 24 - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - <html><head/><body><p>If checked, any mesh node (other than an aabb node) that is set to render 0 and shadow 0 will be converted to a dummy unless the model contains animations.</p></body></html> - - - Cull Invisible Meshes - - - - - - 8 - 94 - 120 - 24 - - - - - 120 - 24 - - - - - 120 - 24 - - - - Render Trimeshes - - - - - - 132 - 94 - 78 - 25 - - - - - 78 - 0 - - - - - 78 - 16777215 - - - - - Default - - - - - All - - - - - None - - - - - - - 8 - 126 - 120 - 24 - - - - - 120 - 24 - - - - - 120 - 24 - - - - Shadows - - - - - - 132 - 125 - 78 - 26 - - - - - 78 - 0 - - - - - 78 - 16777215 - - - - - Default - - - - - All - - - - - None - - - - - - - 8 - 157 - 120 - 24 - - - - - 120 - 24 - - - - - 120 - 24 - - - - <html><head/><body><p>If you choose &quot;<span style=" font-weight:600;">Binary</span>&quot;, all mesh vertices and positions will be snapped to exact multiples of 1/128 metres (0.0078125). The ascii value exported will exactly represent the binary one and, whether you import the resulting model into NWN, nwnmdlcomp, Gmax/3dsMax or back into CleanModels 3, the binary value inside that program will exactly correspond to it. So there is no loss of precision.</p><p>If you choose &quot;<span style=" font-weight:600;">Decimal</span>&quot;, vertices and positions will be snapped to multiples of 0.01 metres and exported values will use only as many decimal places as necessary (thus saving characters in the ascii file). This corresponds to the behaviour of NWmax. Although GMax and 3dsMax work in binary floats internally, NWMax doesn't export in binary precision. (Even if you select the &quot;compile exported model&quot; option, it exports the model in ascii first, discarding the precision, and then compiles it from that).</p><p>&quot;<span style=" font-weight:600;">Fine</span>&quot; is like decimal but 10 times smaller, with a snap interval of 1 millimetre (0.001 metres, one tenth of a unit in 3dsMax or GMax) and will be exported with up to 3 decimal places. This is mainly intended for items and character parts where you want the advantage of snapping and welding but the usual 1cm snap is too coarse and would result in model detail being lost.</p><p>Which one is best, really depends on what you are going to do with the exported models. If you are going to load them straight into NWN or run them through nwnmdlcomp, binary precision is best. If you are going to re-import them into Gmax or 3dsMax, you might as well use decimal precision (and save a few bytes of file size) because that is all you will get back out again anyway.</p><p>If you are working on tiles, you should use the same precision across the entire tileset, otherwise you may get geometry mismatches between one tile and its neighbours in an area.</p><p>Snapping is done after orientations are reset and before repivoting. Vertices are held fixed in absolute position while node positions are snapped, then they themselves are snapped. This avoids problems with rounding errors.</p><p>Snapping is not applied to the positions of the parents of animmesh nodes, nor to the parents of nodes which have animated positions or positionkeys.</p></body></html> - - - Snap - - - - - - 132 - 157 - 78 - 25 - - - - - 0 - 0 - - - - - 78 - 0 - - - - - 78 - 16777215 - - - - <html><head/><body><p>If you choose &quot;<span style=" font-weight:600;">Binary</span>&quot;, all mesh vertices and positions will be snapped to exact multiples of 1/128 metres (0.0078125). The ascii value exported will exactly represent the binary one and, whether you import the resulting model into NWN, nwnmdlcomp, Gmax/3dsMax or back into CleanModels 3, the binary value inside that program will exactly correspond to it. So there is no loss of precision.</p><p>If you choose &quot;<span style=" font-weight:600;">Decimal</span>&quot;, vertices and positions will be snapped to multiples of 0.01 metres and exported values will use only as many decimal places as necessary (thus saving characters in the ascii file). This corresponds to the behaviour of NWmax. Although GMax and 3dsMax work in binary floats internally, NWMax doesn't export in binary precision. (Even if you select the &quot;compile exported model&quot; option, it exports the model in ascii first, discarding the precision, and then compiles it from that).</p><p>&quot;<span style=" font-weight:600;">Fine</span>&quot; is like decimal but 10 times smaller, with a snap interval of 1 millimetre (0.001 metres, one tenth of a unit in 3dsMax or GMax) and will be exported with up to 3 decimal places. This is mainly intended for items and character parts where you want the advantage of snapping and welding but the usual 1cm snap is too coarse and would result in model detail being lost.</p><p>Which one is best, really depends on what you are going to do with the exported models. If you are going to load them straight into NWN or run them through nwnmdlcomp, binary precision is best. If you are going to re-import them into Gmax or 3dsMax, you might as well use decimal precision (and save a few bytes of file size) because that is all you will get back out again anyway.</p><p>If you are working on tiles, you should use the same precision across the entire tileset, otherwise you may get geometry mismatches between one tile and its neighbours in an area.</p><p>Snapping is done after orientations are reset and before repivoting. Vertices are held fixed in absolute position while node positions are snapped, then they themselves are snapped. This avoids problems with rounding errors.</p><p>Snapping is not applied to the positions of the parents of animmesh nodes, nor to the parents of nodes which have animated positions or positionkeys.</p></body></html> - - - - None - - - - - Binary - - - - - Decimal - - - - - Fine - - - - - - - 8 - 188 - 120 - 24 - - - - - 120 - 24 - - - - - 120 - 24 - - - - Snap tverts - - - - - - 132 - 188 - 78 - 25 - - - - - 0 - 0 - - - - - 78 - 0 - - - - - 78 - 16777215 - - - - - No - - - - - 256 - - - - - 512 - - - - - 1024 - - - - - - true - - - - 8 - 219 - 208 - 24 - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - <html><head/><body><p>Only applicable to <span style=" font-weight:600;">Character</span> model classification</p><p>If checked, meshes that match the specified bitmap key(s) entered below are re-parented to an '<span style=" font-weight:600;">a'</span> node just as if they were splotches in a tile, unless the model has a non-NULL supermodel. An '<span style=" font-weight:600;">a'</span> node will be created or repaired if necessary.</p></body></html> - - - Placeable with transparency - - - - - - - 530 - 10 - 291 - 271 - - - - - 0 - 1 - - - - <html><head/><body><p>Bad pivots are one of the causes of shadow errors, particularly in tiles. The pivot of a mesh is regarded as &quot;bad&quot; if the mesh has any faces whose outward normals are directed towards the pivot rather than away from it.</p><p>Unless you set the Repivot option to &quot;none&quot; in the GUI or if there is a non-NULL supermodel, the program checks the pivots of all trimesh nodes that are set to shadow 1, except nodes whose position or orientation is changed in an animation. If it finds a bad pivot, it will try to fix it by moving the pivot to a position inside the mesh where it is no longer &quot;bad&quot;. If it succeeds, it will say so in the detailed log. However, this is not always possible. A common example is a door frame or window frame where the sides are facing each other inwards. Wherever you put the pivot, it would be in front of at least one of the sides Often, these situations can be resolved by splitting the mesh into suitable pieces, each of which can be successfully pivoted. There is an option to allow the program to try to do this automatically for you. If you set the Repivot option to &quot;all&quot;, the program will repivot nodes to conform to its own algorithms, even if their existing position is not actually &quot;bad&quot;. This may be useful on a first pass through a raw tileset, for example.The program will first try setting the pivot at the top of the object. If this is unsatisfactory, it will next try the centroid of the centroids of the faces of the object. If that also fails, it will compute the polyhedron within which the pivot must lie and if that polyhedron is convex it will use the centre of its bounding box as a pivot.</p><p>If snap is being used, the new position of the pivot will be snapped if possible. Sometimes, if the pivoting is critical, this is not possible. For example, if the program has calculated that the only possible position for a pivot is between 1.234 and 1.235, snapping it to 1.23 is not going to work. In this case, the pivot will be set to 1.2345 and the vertex co-ordinates, which are expressed relative to the pivot, will be released so that their absolute positions (relative to the rest of the geometry) can remain exact.</p><p>In the case of tile models, the program will look for a pivot within the XY boundaries of the tile. Objects in tiles do not cast shadows if they have a pivot below Z=0 so you may want to tell CM3 to keep pivots on or above the Z=0 plane. The GUI option โ€œPivots below Z=0โ€ controls this. The third option 'slice' will cause meshes that cross the Z=0 place to be sliced at Z=0, the upper part repivoted and the lower part set to shadow 0.</p><p>Repivoting is not recommended for character models (it is likely to ruin animations) or doors (which do not cast shadows anyway).</p><p>The memory needed to find the bounding polyhedron increases as the third power of the number of faces. </p><p>For trimesh nodes of up to 150 faces, the program will use an exact algorithm that will certainly succeed in fixing the pivot if it is geometrically possible. Beyond 150 faces and up to 400 faces it will use a looser algorithm that is less memory-intensive and will usually succeed (about 99% of the time) but may occasionally fail. Beyond 400 faces it will not even try.</p><p>Occasionally, repositioning a difficult pivot may be achieved by exploiting rounding errors in the calculation of very small distances and the pivot may be placed the &quot;wrong side&quot; of a face by a microscopic distance. If this happens, a warning will be printed in the detailed log file.</p></body></html> - - - QFrame::NoFrame - - - QFrame::Raised - - - - - 10 - 33 - 276 - 225 - - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - 12 - 110 - 251 - 106 - - - - <html><head/><body><p>If you turn on the &quot;splitting&quot; option, when the program fails to find a satisfactory pivot position for an object, it will split the object into parts that can each be pivoted separately. It creates new names for the parts by adding suffix numbers to the original node name (having checked that they are not already in use) and it randomises the wirecolors of the new objects. If it fails to repivot one of the parts (which can happen with a really twisted geometry) it will set its wirecolor to red (1 0 0).</p><p>When splitting an object, the program first separates isolated objects that are not connected to each other by common edges. If that isn't enough, it tries splitting objects into distinct smoothing group. Then it tries detaching pieces of floor and anything below them. After that, it looks for pieces that are entirely convex or entirely concave and detaches those. Finally it looks for groups of faces that form plane polygons. When it separates a piece, it checks to see if that piece and the remaining part can be pivoted. If they can, it need go no further. Otherwise, it will try splitting them again in another way.</p><p>There are some options in the GUI to control the splitting process. You can tell it to use smoothing groups, as described above, or to totally ignore them (if you think the models are not well smoothed anyway) or to protect smoothing groups and not allow them to be split further even if the pivot is still bad.</p><p>There is also a spinner to set the minimum size (number of faces) of pieces after splitting. Setting it to 4 is often a good choice but you might need to try different values on problematic models. Usually, it works better for concave pieces to be identified before convex ones. For some geometries, however, it may be better to do it the other way around. The radio button in the GUI lets you to choose which way you want the program to do it. If you find a particular model is problematic and the program seems to generate a large number of pieces which it can't repivot, try changing this option and running the original file through the program again.When splitting shadow meshes (trimesh nodes set to render 0 shadow 1), the option to protect smoothing groups is ignored for that node (because smoothing is meaningless if the node is not rendered) and the minimum piece size of 2 is used, regardless of GUI options. This is so that, if a node is so convoluted that it cannot be pivoted without breaking smoothing, you can try creating a shadow mesh for it that can certainly be repivoted.</p><p>If a trimesh node cannot be successfully repivoted, even with splitting, you can opt to move it to the top, middle or bottom of the object or to leave it where it is. 'Top' means the centroid of the top-most vertices; 'Middle' means the centroid of those vertices that lie at or above Z=0 and 'Bottom' means the centroid of the bottom-most vertices. One of these choices will quite often, but not always, resolve remaining shadow errors but a warning is still printed in the log because visual inspection of the shadows on the model will still be needed.</p></body></html> - - - QFrame::WinPanel - - - QFrame::Sunken - - - - - 10 - 40 - 231 - 30 - - - - - - - - 0 - 0 - - - - Smoothing Groups - - - - - - - - Use - - - - - Ignore - - - - - Protect - - - - - - - - - - 10 - 10 - 232 - 31 - - - - - QLayout::SetMinimumSize - - - - - Sub-Object Minimum Faces - - - - - - - 2 - - - 8 - - - - - - - - - 10 - 70 - 231 - 30 - - - - - QLayout::SetMinimumSize - - - - - - 0 - 0 - - - - Split First - - - - - - - - Convex - - - - - Concave - - - - - - - - - - - 13 - 10 - 251 - 96 - - - - - - - Pivots Below Z=0 - - - - - - - - Disallow - - - - - Allow - - - - - Slice - - - - - - - - Move Bad Pivots - - - - - - - - No - - - - - Top - - - - - Middle - - - - - Bottom - - - - - - - - - 140 - 0 - - - - <html><head/><body><p>If you turn on the &quot;splitting&quot; option, when the program fails to find a satisfactory pivot position for an object, it will split the object into parts that can each be pivoted separately. It creates new names for the parts by adding suffix numbers to the original node name (having checked that they are not already in use) and it randomises the wirecolors of the new objects. If it fails to repivot one of the parts (which can happen with a really twisted geometry) it will set its wirecolor to red (1 0 0).</p><p>When splitting an object, the program first separates isolated objects that are not connected to each other by common edges. If that isn't enough, it tries splitting objects into distinct smoothing group. Then it tries detaching pieces of floor and anything below them. After that, it looks for pieces that are entirely convex or entirely concave and detaches those. Finally it looks for groups of faces that form plane polygons. When it separates a piece, it checks to see if that piece and the remaining part can be pivoted. If they can, it need go no further. Otherwise, it will try splitting them again in another way.</p><p>There are some options in the GUI to control the splitting process. You can tell it to use smoothing groups, as described above, or to totally ignore them (if you think the models are not well smoothed anyway) or to protect smoothing groups and not allow them to be split further even if the pivot is still bad.</p><p>There is also a spinner to set the minimum size (number of faces) of pieces after splitting. Setting it to 4 is often a good choice but you might need to try different values on problematic models. Usually, it works better for concave pieces to be identified before convex ones. For some geometries, however, it may be better to do it the other way around. The radio button in the GUI lets you to choose which way you want the program to do it. If you find a particular model is problematic and the program seems to generate a large number of pieces which it can't repivot, try changing this option and running the original file through the program again.When splitting shadow meshes (trimesh nodes set to render 0 shadow 1), the option to protect smoothing groups is ignored for that node (because smoothing is meaningless if the node is not rendered) and the minimum piece size of 2 is used, regardless of GUI options. This is so that, if a node is so convoluted that it cannot be pivoted without breaking smoothing, you can try creating a shadow mesh for it that can certainly be repivoted.</p><p>If a trimesh node cannot be successfully repivoted, even with splitting, you can opt to move it to the top, middle or bottom of the object or to leave it where it is. 'Top' means the centroid of the top-most vertices; 'Middle' means the centroid of those vertices that lie at or above Z=0 and 'Bottom' means the centroid of the bottom-most vertices. One of these choices will quite often, but not always, resolve remaining shadow errors but a warning is still printed in the log because visual inspection of the shadows on the model will still be needed.</p></body></html> - - - Allow Splitting - - - - - - - - - - 13 - 4 - 51 - 20 - - - - Repivot - - - - - - 68 - 0 - 111 - 28 - - - - - If needed - - - - - All - - - - - None - - - - - - - - 50 - 10 - 211 - 28 - - - - You can type addresses relative to the CM3 install directory using "." and ".." - - - - - - 512 - - - - - - 50 - 40 - 211 - 28 - - - - You can type addresses relative to the CM3 install directory using "." and ".." - - - - - - 512 - - - - - - 90 - 70 - 211 - 28 - - - - <html><head/><body><p>Changing the filename pattern allows you to focus on just a few models at a time it you want to. Use Unix-style patterns, for example, <span style=" font-weight:600;">a_*[bf]a_*.mdl</span> to pick out certain animation supermodels or <span style=" font-weight:600;">tcxy?_[a-d]*.mdl</span> to pick out just the a,b,c and d series tiles of the tcxy tileset.</p></body></html> - - - 32 - - - - - - 10 - 160 - 291 - 41 - - - - QFrame::NoFrame - - - QFrame::Raised - - - - - 0 - 3 - 291 - 31 - - - - - QLayout::SetMinimumSize - - - - - <html><head/><body><p>Allows you to override the classification of models. It should normally be left on &quot;<span style=" font-weight:600;">Automatic</span>&quot; and the program will assume that the classification parameter in each input model is true and correct and process it and output it accordingly. However, if you know that all of the models are supposed to be of a single type and suspect that some may have the wrong classification, you can set the drop down to one of the specific options to force all the models to be processed and output as if they were of that particular type. There is no distinct classification for placeables because in NWN, placeables are classed as '<span style=" font-weight:600;">Characters'</span>. Note that, if the classification is set to anything other than '<span style=" font-weight:600;">Tile</span>' or '<span style=" font-weight:600;">Automatic</span>', the program will not attempt to load edge tile 2da's.</p></body></html> - - - Model Classification - - - - - - - <html><head/><body><p>Allows you to override the classification of models. It should normally be left on &quot;<span style=" font-weight:600;">Automatic</span>&quot; and the program will assume that the classification parameter in each input model is true and correct and process it and output it accordingly. However, if you know that all of the models are supposed to be of a single type and suspect that some may have the wrong classification, you can set the drop down to one of the specific options to force all the models to be processed and output as if they were of that particular type. There is no distinct classification for placeables because in NWN, placeables are classed as '<span style=" font-weight:600;">Characters'</span>. Note that, if the classification is set to anything other than '<span style=" font-weight:600;">Tile</span>' or '<span style=" font-weight:600;">Automatic</span>', the program will not attempt to load edge tile 2da's.</p></body></html> - - - false - - - Automatic - - - - Automatic - - - - - Character - - - - - Door - - - - - Effect - - - - - Item - - - - - Tile - - - - - - - - - - - 90 - 100 - 211 - 28 - - - - <html><head/><body><p>Will record details of all the faults the program has identified and all the fixes it has done. It may be quite long but it is there so that you can see what kinds of faults the models had in them, that needed fixing.</p></body></html> - - - 32 - - - - - - 90 - 130 - 211 - 28 - - - - <html><head/><body><p>A summary log that only lists the remaining, unfixed faults that will need to be investigated and repaired manually. Serious errors that prevented a model being exported at all are flagged in the summary file with &quot;<span style=" font-weight:600;">*** Error</span>&quot; so that they can be found quickly.</p></body></html> - - - 32 - - - - - - 540 - 276 - 276 - 38 - - - - Model rescaling. Press the lock button to synchronize the X, Y and Z scaling. A scale of 1,1,1 is considered off. - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - 58 - 5 - 60 - 29 - - - - - 60 - 0 - - - - - 60 - 16777215 - - - - X Scale - - - 2 - - - 1.000000000000000 - - - - - - 120 - 5 - 60 - 29 - - - - - 60 - 0 - - - - - 60 - 16777215 - - - - Y Scale - - - 2 - - - 1.000000000000000 - - - - - - 6 - 9 - 52 - 20 - - - - - 52 - 0 - - - - - 52 - 16777215 - - - - Rescale - - - - - - 182 - 5 - 60 - 29 - - - - - 60 - 0 - - - - - 60 - 16777215 - - - - Z Scale - - - 2 - - - 1.000000000000000 - - - - - - 246 - 7 - 24 - 24 - - - - - 24 - 0 - - - - - 24 - 16777215 - - - - Lock Y and Z with X scale. - - - - - - - :/icons/unlock-rescale:/icons/unlock-rescale - - - true - - - false - - - false - - - - - - - 10 - 200 - 291 - 114 - - - - Clean Summary - - - - - 10 - 30 - 261 - 20 - - - - Files Detected - - - - - - 10 - 57 - 261 - 20 - - - - Files Cleaned - - - - - - 10 - 84 - 221 - 20 - - - - Failures - - - - - - - 11 - 72 - 75 - 20 - - - - File Pattern - - - - - - 11 - 132 - 63 - 20 - - - - Summary - - - - - - 11 - 12 - 16 - 20 - - - - In - - - - - - 11 - 102 - 25 - 20 - - - - Log - - - - - - 11 - 42 - 26 - 20 - - - - Out - - - - - - - 0 - 0 - - - - - 858 - 324 - - - - - - - Tiles - - - <html><head/><body><p>Fixes specific to tiles.</p></body></html> - - - - - 301 - 34 - 261 - 203 - - - - QFrame::StyledPanel - - - - - 11 - 4 - 241 - 201 - - - - - - - - 160 - 20 - - - - - 16777215 - 20 - - - - Water Bitmap Key(s) - - - - - - - - 0 - 27 - - - - - 16777215 - 27 - - - - Separate multiple keys by spaces - - - - - - - - 0 - 20 - - - - - 16777215 - 20 - - - - <html><head/><body><p>The &quot;Dynamic Water&quot; sub-option offers three choices.<ul><li>Select &quot;<span style=" font-weight:600;">yes</span>&quot; if you want the program to ensure that water surfaces descend from the tile's 'a<span style=" font-weight:600;">'</span> node but don't want to do anything special with animmesh animations. The program will create the '<span style=" font-weight:600;">a</span>' node if it needs to and move water meshes up and across the hierarchy as appropriate. This option should normally be chosen if there is walkable water (such as stream and pools) in the tileset. (If the water is not attached to the '<span style=" font-weight:600;">a</span>' node, creatures' feet will be invisible when they walk through the water).</li><li>The &quot;<span style=" font-weight:600;">no</span>&quot; option de-animates water, removing it from the '<span style=" font-weight:600;">a</span>' node, and if it is animmesh water it strips it back to trimesh and deletes any associated animations. This option is mainly intended for repairing really broken tiles and getting back to something safe.</li><li>The &quot;<span style=" font-weight:600;">wavy</span>&quot; option allows you to add or repair swooshy sea waves like the ones in the TNO01 tileset. It moves water onto the '<span style=" font-weight:600;">a</span>' node (if it wasn't already), converts it from trimesh to animmesh (if it wasn't already), forces its pivot to be at the tile origin with zero orientation, snaps and welds vertices, tessellates the mesh by halving edges until all edges are &lt; 200cm, creates an animloop03 animation is there isn't one and creates new animmesh animations, overwriting any previous ones.</li></ul></p></body></html> - - - Dynamic Water - - - - - - - - 0 - 28 - - - - - 16777215 - 28 - - - - <html><head/><body><p>The &quot;Dynamic Water&quot; sub-option offers three choices.<ul><li>Select &quot;<span style=" font-weight:600;">yes</span>&quot; if you want the program to ensure that water surfaces descend from the tile's 'a<span style=" font-weight:600;">'</span> node but don't want to do anything special with animmesh animations. The program will create the '<span style=" font-weight:600;">a</span>' node if it needs to and move water meshes up and across the hierarchy as appropriate. This option should normally be chosen if there is walkable water (such as stream and pools) in the tileset. (If the water is not attached to the '<span style=" font-weight:600;">a</span>' node, creatures' feet will be invisible when they walk through the water).</li><li>The &quot;<span style=" font-weight:600;">no</span>&quot; option de-animates water, removing it from the '<span style=" font-weight:600;">a</span>' node, and if it is animmesh water it strips it back to trimesh and deletes any associated animations. This option is mainly intended for repairing really broken tiles and getting back to something safe.</li><li>The &quot;<span style=" font-weight:600;">wavy</span>&quot; option allows you to add or repair swooshy sea waves like the ones in the TNO01 tileset. It moves water onto the '<span style=" font-weight:600;">a</span>' node (if it wasn't already), converts it from trimesh to animmesh (if it wasn't already), forces its pivot to be at the tile origin with zero orientation, snaps and welds vertices, tessellates the mesh by halving edges until all edges are &lt; 200cm, creates an animloop03 animation is there isn't one and creates new animmesh animations, overwriting any previous ones.</li></ul></p></body></html> - - - - Yes - - - - - No - - - - - Wavy - - - - - - - - - 0 - 34 - - - - - 16777215 - 34 - - - - <html><head/><body><p>The wave pattern for wavy water is randomised afresh for each tile so if you don't like the pattern it gives you, run the same tile through CM3 again and you will get a different one. If a tile contains more than one water mesh, the same random keys are used for all of them. Think twice before adding waves to a tileset. There are pitfalls.</p><p><br/></p><p>โ€ข Wavy water conflicts with shinywater. If the water bitmap has a .txi that defines it as shinywater and you try to animate it, the texture will not paint properly in clients that have shinywater enabled and, depending on the graphics card, it might even crash. The bitmap used for the wavy sea in TNO01 is not shinywater.</p><p><br/></p><p>โ€ข The NWN engine does not apply rotatetexture to animmesh objects, even if the flag is set to 1. To avoid getting visible seams in wavy water at tile edges, your water bitmap needs to have 8-fold rotational symmetry around its perimeter. Most water bitmaps do not have this so you may need to craft your own.</p><p><br/></p><p>โ€ข If you add waves to a tile, you can no longer re-import it into Gmax or 3dsMax because NWMax cannot import animmesh animations. Only add waves at the last stage when all other editing has been finalised. You can work around this problem by using the &quot;no&quot; option to remove theanimmesh, do the editing in Gmax or 3dsMax and then rerun CM3 with the &quot;wavy&quot; option set to reconstruct the waves. You won't get the same wave pattern as before, though.</p><p><br/></p><p>There is a spinner for selecting an average wave height between 10cm and 1.0m in units of 10cm. Setting it to 5 is usually about right but it rather depends on what objects lie in the tile at the water's edge. You might use setting of 9 or 10 for dramatic waves pounding the foot of towering cliffs but more like 1 or 2 for ripples gently lapping a sloping beach.</p><p><br/></p><p>Only interior vertices are animated. Ones along the tile edge are kept static otherwise they wouldn't match the next tile.</p><p><br/></p><p>Note that the option to redo UV the mapping is not available for water that has already been made wavy.</p></body></html> - - - QFrame::WinPanel - - - QFrame::Sunken - - - - - 10 - 3 - 144 - 30 - - - - - 0 - 0 - - - - - 0 - 30 - - - - - 16777215 - 30 - - - - Wave Height (x10cm) - - - - - - 161 - 3 - 56 - 30 - - - - - 56 - 30 - - - - - 56 - 30 - - - - 1 - - - 0.100000000000000 - - - 10.000000000000000 - - - 1.000000000000000 - - - - - - - - - 0 - 20 - - - - - 16777215 - 20 - - - - Water rotatetexture - - - - - - - - 0 - 28 - - - - - 16777215 - 28 - - - - - No change - - - - - 1 - - - - - 0 - - - - - - - - - 0 - 20 - - - - - 16777215 - 20 - - - - Re-tile water - - - - - - - - 0 - 28 - - - - - 16777215 - 28 - - - - - No change - - - - - 1x1 - - - - - 2x2 - - - - - 3x3 - - - - - - - - - - - 10 - 10 - 281 - 306 - - - - - 0 - 0 - - - - - 0 - 306 - - - - - 16777215 - 306 - - - - Tweaks - - - - - 12 - 106 - 110 - 30 - - - - - 0 - 30 - - - - - 16777215 - 30 - - - - <html><head/><body><p>If you select &quot;Slice for Tilefading&quot;, you can then specify a slice height in the range 2 to 10 metres in units of 10cm. For each tile separately, a slice plane is computed at that height above the highest walkable point in the walkmesh (after the walkmesh has been repaired if necessary, of course), rounded down to the nearest 10cm. Different tiles may therefore be sliced at different heights but it will be generally appropriate to the terrain. As a special case, if there are animated objects in the tile that would project above the slice plane, the slice plane for that tile will be raised to clear them (it tends to look silly, otherwise).</p><p>Slicing is not applied to edge tiles, to unrendered objects, to objects already set to tilefade, to objects with animation keys or any other objects that descend from the tile's 'a' node. Otherwise, objects clear above the slice plane are set to fade, objects clear below it to not fade and objects that cross it are sliced to create fading tops and non-fading bottom parts.</p><p>If Tree Foliage is also set to &quot;tilefade&quot;, as an exception, foliage objects are not sliced. Quite simply, if the foliage object extends above the slice plane the whole object is set to fade otherwise it is set to not fade.</p><p>Black bases are automatically constructed for fading objects and set to tilefade 2. The algorithm used gives good results in most cases but may fail if the sliced meshes have annular holes and so the bases it generates should always be checked visually for glitches. Before constructing bases it deletes any existing ones so if you know that your tiles already have satisfactorily bases, it is better to set tilefade to &quot;no&quot; to preserve the status quo ante rather than risk disrupting them.</p><p>The &quot;undo&quot; option removes tilefading completely. Objects set to tilefade 2 (usually, black tops that appear when upper parts fade and uncover them) are deleted and other objects with tilefade 1 (fade) or 4 (neighbour) are reset to tilefade 0. It does not weld together meshes that have previously been sliced apart but the โ€œMeshmerge by Bitmapโ€ option may be helpful in such cases.</p></body></html> - - - Slice for Tilefade - - - - - - 12 - 69 - 132 - 30 - - - - - 132 - 30 - - - - - 16777215 - 30 - - - - Raise/Lower tileset - - - - - - 12 - 208 - 199 - 30 - - - - - 0 - 0 - - - - - 0 - 30 - - - - - 16777215 - 30 - - - - Change Walkmesh material - - - - - - 139 - 106 - 132 - 30 - - - - - 0 - 0 - - - - - 132 - 30 - - - - - 132 - 30 - - - - <html><head/><body><p>If you select &quot;Slice for Tilefading&quot;, you can then specify a slice height in the range 2 to 10 metres in units of 10cm. For each tile separately, a slice plane is computed at that height above the highest walkable point in the walkmesh (after the walkmesh has been repaired if necessary, of course), rounded down to the nearest 10cm. Different tiles may therefore be sliced at different heights but it will be generally appropriate to the terrain. As a special case, if there are animated objects in the tile that would project above the slice plane, the slice plane for that tile will be raised to clear them (it tends to look silly, otherwise).</p><p>Slicing is not applied to edge tiles, to unrendered objects, to objects already set to tilefade, to objects with animation keys or any other objects that descend from the tile's 'a' node. Otherwise, objects clear above the slice plane are set to fade, objects clear below it to not fade and objects that cross it are sliced to create fading tops and non-fading bottom parts.</p><p>If Tree Foliage is also set to &quot;tilefade&quot;, as an exception, foliage objects are not sliced. Quite simply, if the foliage object extends above the slice plane the whole object is set to fade otherwise it is set to not fade.</p><p>Black bases are automatically constructed for fading objects and set to tilefade 2. The algorithm used gives good results in most cases but may fail if the sliced meshes have annular holes and so the bases it generates should always be checked visually for glitches. Before constructing bases it deletes any existing ones so if you know that your tiles already have satisfactorily bases, it is better to set tilefade to &quot;no&quot; to preserve the status quo ante rather than risk disrupting them.</p><p>The &quot;undo&quot; option removes tilefading completely. Objects set to tilefade 2 (usually, black tops that appear when upper parts fade and uncover them) are deleted and other objects with tilefade 1 (fade) or 4 (neighbour) are reset to tilefade 0. It does not weld together meshes that have previously been sliced apart but the โ€œMeshmerge by Bitmapโ€ option may be helpful in such cases.</p></body></html> - - - - Yes - - - - - No - - - - - Undo - - - - - - - 178 - 32 - 92 - 30 - - - - - 0 - 0 - - - - - 92 - 30 - - - - - 92 - 30 - - - - <html><head/><body><p>If enable it will attempt to adjust walkmesh vertices horizontally to remove tucks and folds and re-align overhanging faces to have positive slopes. It will typically succeed in about 90% of cases but sometimes the mesh may be too badly twisted for it to know what to do and you will get a warning that the walkmesh still has mis- oriented faces. If you use this option, you are advised to check the walkmesh visually afterwards in case it has been distorted inappropriately.</p><p>When repairing overhangs, the program straightens any zigzag lines of vertices down tile edges first, before adjusting interior vertices. It will straighten identical zigzigs in identical ways on all tiles so that if the walkmesh was continuous across certain tile boundaries before the repairs, it will still be continuous afterwards. This continuity may be lost, however, if not all tiles in the tileset are processed or if the meshing of the tile edges was already inconsistent. In such cases, the &quot;Interior only&quot; option should be used. This will ensure that tile-edge vertices are not moved at all. This may mean that fewer face overhangs can be repaired by the program and more work will have to be done by hand-crafting, but it does avoid the risk of causing disjunctions in the walkmesh between tiles that had not been present before.</p><p>When reporting inverted aabb faces that it could not fix, the program lists their face numbers. Note that faces in NWN are numbered from zero. If you look for those faces in Gmax or 3dsMax, you have to add 1 mentally because those programs number faces from 1. Inverted faces are sometimes very small or hidden behind other faces and hard to see, so this may help to track them down.</p></body></html> - - - - Yes - - - - - No - - - - - Interior only - - - - - - - 12 - 32 - 148 - 30 - - - - - 0 - 30 - - - - - 16777215 - 30 - - - - <html><head/><body><p>If enable it will attempt to adjust walkmesh vertices horizontally to remove tucks and folds and re-align overhanging faces to have positive slopes. It will typically succeed in about 90% of cases but sometimes the mesh may be too badly twisted for it to know what to do and you will get a warning that the walkmesh still has mis- oriented faces. If you use this option, you are advised to check the walkmesh visually afterwards in case it has been distorted inappropriately.</p><p>When repairing overhangs, the program straightens any zigzag lines of vertices down tile edges first, before adjusting interior vertices. It will straighten identical zigzigs in identical ways on all tiles so that if the walkmesh was continuous across certain tile boundaries before the repairs, it will still be continuous afterwards. This continuity may be lost, however, if not all tiles in the tileset are processed or if the meshing of the tile edges was already inconsistent. In such cases, the &quot;Interior only&quot; option should be used. This will ensure that tile-edge vertices are not moved at all. This may mean that fewer face overhangs can be repaired by the program and more work will have to be done by hand-crafting, but it does avoid the risk of causing disjunctions in the walkmesh between tiles that had not been present before.</p><p>When reporting inverted aabb faces that it could not fix, the program lists their face numbers. Note that faces in NWN are numbered from zero. If you look for those faces in Gmax or 3dsMax, you have to add 1 mentally because those programs number faces from 1. Inverted faces are sometimes very small or hidden behind other faces and hard to see, so this may help to track them down.</p></body></html> - - - Repair aabb overhangs - - - - - - 139 - 69 - 72 - 30 - - - - - 0 - 0 - - - - - 72 - 30 - - - - - 72 - 30 - - - - <html><head/><body><p>This may be useful when importing tiles from another tileset where the ground height is different, or to raise an entire tileset to overcome the problem of shadows cutting off at Z=0. CM3 does the shifting after all other fixes have been made to the tile, using these rules:<ul></li><li>The tile dummy (Aurora Base) and 'a' node are not moved</li><li>The walkmesh position is not changed but its vertices are adjusted individually up or down</li><li>All other objects parented directly to the tile dummy have their positions adjusted up or down</li><li>animmesh objects (which will be parented to the 'a' node) are not moved but have their vertices and animverts adjusted</li><li>All other objects parented directly to the 'a' node have their positions and positionkeys adjusted</li></ul></body></html> - - - - No - - - - - Raise - - - - - Lower - - - - - - - 12 - 244 - 259 - 38 - - - - - 0 - 38 - - - - - 16777215 - 38 - - - - QFrame::WinPanel - - - QFrame::Sunken - - - - - 20 - 8 - 35 - 20 - - - - From - - - - - - 62 - 4 - 49 - 29 - - - - 30 - - - - - - 117 - 8 - 16 - 20 - - - - to - - - - - - 140 - 4 - 49 - 29 - - - - 30 - - - - - - - 217 - 68 - 54 - 32 - - - - - 0 - 0 - - - - - 54 - 32 - - - - - 54 - 32 - - - - <html><head/><body><p>This may be useful when importing tiles from another tileset where the ground height is different, or to raise an entire tileset to overcome the problem of shadows cutting off at Z=0. CM3 does the shifting after all other fixes have been made to the tile, using these rules:<ul></li><li>The tile dummy (Aurora Base) and 'a' node are not moved</li><li>The walkmesh position is not changed but its vertices are adjusted individually up or down</li><li>All other objects parented directly to the tile dummy have their positions adjusted up or down</li><li>animmesh objects (which will be parented to the 'a' node) are not moved but have their vertices and animverts adjusted</li><li>All other objects parented directly to the 'a' node have their positions and positionkeys adjusted</li></ul></body></html> - - - 1.000000000000000 - - - 10.000000000000000 - - - 1.000000000000000 - - - - - - 12 - 142 - 259 - 60 - - - - - 0 - 60 - - - - - 16777215 - 60 - - - - QFrame::WinPanel - - - QFrame::Sunken - - - - - 180 - 14 - 70 - 29 - - - - - 70 - 0 - - - - - 70 - 16777215 - - - - 20.000000000000000 - - - 100.000000000000000 - - - - - - 11 - 9 - 127 - 39 - - - - Slice Height above Walkmesh (x10cm) - - - true - - - - - - - - 301 - 6 - 171 - 30 - - - - - 0 - 30 - - - - - 16777215 - 30 - - - - <html><head/><body><p>The program recognises water surfaces by their bitmap names. It has some Bioware texture names built-in but it will also look for a special substring (the &quot;key&quot;) in every bitmap name. By default the key is &quot;water&quot; and this works for most tilesets but you can change this in the GUI if your tileset uses some other convention. You can enter more than one key if you separate them by spaces.</p><p>When water connects across tile boundaries, it tends to look better if the bitmap keeps the same compass orientation regardless of rotations of the tiles. You can achieve this by setting rotatetexture to 1 on all the water surface meshes. The program can do this for you automatically if you wish. Otherwise, you can set it to 0 (not recommended) or leave it alone. There is also an option to redo the UV mapping of water planes to a whole number of repeat units across the tile width. The choice of 1x1, 2x2 or 3x3 is offered. These mappings anchor the bottom left corner of the bitmap (U=V=0) to the bottom left corner of the tile (X= Y= -5) and compute the U,V for each vertex from its X,Y coordinate. When applied across a tileset in conjunction with the rotatetexture flag, this will also help to prevent visible seams in the water at tile boundaries. For best results, you need to use a water bitmap that is seamless across its edges.</p></body></html> - - - Apply Water Fixups - - - - - - 301 - 289 - 261 - 28 - - - - - 16777215 - 28 - - - - Separate multiple keys by spaces - - - <html><head/><body><p>Bitmap keys for &quot;<span style=" font-weight:600;">Splotches</span>&quot;, that is partially-transparent overlays, can be specified in a similar way to foliage keys. You have the option to &quot;Animate&quot;, that is force them to descend from the 'a' node, or not. Because splotches are usually placed a very small distance above their underlying surfaces (to avoid texture flickering) and it may be important to maintain that clearance, the vertices of splotches will never be snapped.</p></body></html> - - - - - - 301 - 266 - 146 - 20 - - - - - 0 - 20 - - - - - 16777215 - 20 - - - - <html><head/><body><p>Bitmap keys for &quot;<span style=" font-weight:600;">Splotches</span>&quot;, that is partially-transparent overlays, can be specified in a similar way to foliage keys. You have the option to &quot;Animate&quot;, that is force them to descend from the 'a' node, or not. Because splotches are usually placed a very small distance above their underlying surfaces (to avoid texture flickering) and it may be important to maintain that clearance, the vertices of splotches will never be snapped.</p></body></html> - - - Splotch Bitmap Key(s) - - - - - - 572 - 34 - 245 - 91 - - - - - - - - 97 - 16777215 - - - - <html><head/><body><p>Foliage objects are recognized by their bitmap names. By default, names containing &quot;leaf&quot;, &quot;leaves&quot;, &quot;trefol&quot; and &quot;treefol&quot; are caught but you can enter an alternate Foliage Bitmap Keys if your tileset uses something different. Separate multiple keys by spaces. For foliage, there are five options:<ul><li> Set all foliage to tilefade. This is particularly useful in forest and jungle tilesets. Note that this option has no effect unless &quot;Slice for Tilefading&quot; is also set to &quot;yes&quot;. Note also that tilefading implies de-animating.</li><li>The &quot;Animate&quot; option moves the foliage objects, if necessary, onto the tile's 'a' node. This gives better results on some graphics cards that otherwise render bitmaps with transparency (i.e. the holes between the leaves) with unwanted white margins.</li><li>&quot;De-Animate&quot; moves all foliage objects off the 'a' node and makes them static.</li><li>The &quot;No change&quot; option recognises foliage objects but neither adds any to the 'a' node nor removes any from it. It just keeps them the same as it finds them.</li><li>The &quot;Ignore&quot; option doesn't even look for the special bitmap names and just treats pieces of foliage as ordinary objects.</li></ul></p></body></html> - - - - No change - - - - - Tilefade - - - - - Animate - - - - - De-animate - - - - - Ignore - - - - - - - - - 140 - 0 - - - - <html><head/><body><p>Foliage objects are recognized by their bitmap names. By default, names containing &quot;leaf&quot;, &quot;leaves&quot;, &quot;trefol&quot; and &quot;treefol&quot; are caught but you can enter an alternate Foliage Bitmap Keys if your tileset uses something different. Separate multiple keys by spaces. For foliage, there are five options:<ul><li> Set all foliage to tilefade. This is particularly useful in forest and jungle tilesets. Note that this option has no effect unless &quot;Slice for Tilefading&quot; is also set to &quot;yes&quot;. Note also that tilefading implies de-animating.</li><li>The &quot;Animate&quot; option moves the foliage objects, if necessary, onto the tile's 'a' node. This gives better results on some graphics cards that otherwise render bitmaps with transparency (i.e. the holes between the leaves) with unwanted white margins.</li><li>&quot;De-Animate&quot; moves all foliage objects off the 'a' node and makes them static.</li><li>The &quot;No change&quot; option recognises foliage objects but neither adds any to the 'a' node nor removes any from it. It just keeps them the same as it finds them.</li><li>The &quot;Ignore&quot; option doesn't even look for the special bitmap names and just treats pieces of foliage as ordinary objects.</li></ul></p></body></html> - - - Tree Foliage - - - - - - - Foliage Bitmap Key(s) - - - - - - - Separate multiple keys by spaces - - - false - - - - - - - - - 301 - 243 - 259 - 20 - - - - - 0 - 20 - - - - - 16777215 - 20 - - - - <html><head/><body><p>Bitmap keys for &quot;<span style=" font-weight:600;">Splotches</span>&quot;, that is partially-transparent overlays, can be specified in a similar way to foliage keys. You have the option to &quot;Animate&quot;, that is force them to descend from the 'a' node, or not. Because splotches are usually placed a very small distance above their underlying surfaces (to avoid texture flickering) and it may be important to maintain that clearance, the vertices of splotches will never be snapped.</p></body></html> - - - Animate Splotches - - - - - - 572 - 159 - 245 - 158 - - - - - - - <html><head/><body><p>The &quot;<span style=" font-weight:600;">ground</span>&quot; bitmap key allows one or more substrings to be specified separated by spaces, in the same way as the other key fields. The options to set rotatetexture to 0 or 1 or to leave it unchanged function in the same way as the ones for water.</p></body></html> - - - Ground rotatetexture - - - - - - - - 97 - 0 - - - - - 97 - 16777215 - - - - <html><head/><body><p>The &quot;<span style=" font-weight:600;">ground</span>&quot; bitmap key allows one or more substrings to be specified separated by spaces, in the same way as the other key fields. The options to set rotatetexture to 0 or 1 or to leave it unchanged function in the same way as the ones for water.</p></body></html> - - - - No change - - - - - 1 - - - - - 0 - - - - - - - - <html><head/><body><p>The ground key is also used as a filter for the option to add or remove <span style=" font-weight:600;">Tile Edge Chamfers</span>. This is a technique[1] for avoiding 'sparklies' (white pixels) between adjacent tiles. Chamfers or bevelled edges 3cm out and 3cm down are added to the ground planes at tile edges and although they will normally be hidden by the adjacent tiles, they ensure that there are no broken edges that might allow daylight to leak through. They are assigned to a special smoothing group (CM3 uses 21) so that the smoothing of the visible ground plane is not disrupted. Note that transparent surfaces (ones with alpha or alphakeys &lt; 1) and ones that slope up or down more than 30 degrees at the tile edge are not chamfered, to avoid the possibility of the chamfer being accidentally visible above or through the ground of the adjacent tile.</p><p><br/></p><p>1. Rosenkrantz, L., private communication with OldMansBeard, 2008</p></body></html> - - - Tile Edge Chamfers - - - - - - - - 97 - 0 - - - - - 97 - 16777215 - - - - <html><head/><body><p>The ground key is also used as a filter for the option to add or remove <span style=" font-weight:600;">Tile Edge Chamfers</span>. This is a technique[1] for avoiding 'sparklies' (white pixels) between adjacent tiles. Chamfers or bevelled edges 3cm out and 3cm down are added to the ground planes at tile edges and although they will normally be hidden by the adjacent tiles, they ensure that there are no broken edges that might allow daylight to leak through. They are assigned to a special smoothing group (CM3 uses 21) so that the smoothing of the visible ground plane is not disrupted. Note that transparent surfaces (ones with alpha or alphakeys &lt; 1) and ones that slope up or down more than 30 degrees at the tile edge are not chamfered, to avoid the possibility of the chamfer being accidentally visible above or through the ground of the adjacent tile.</p><p><br/></p><p>1. Rosenkrantz, L., private communication with OldMansBeard, 2008</p></body></html> - - - - No change - - - - - Add - - - - - Delete - - - - - - - - <html><head/><body><p>Redo the UV mapping of ground planes to repeat in whole units across tile widths.</p></body></html> - - - Re-tile ground planes - - - - - - - - 97 - 0 - - - - - 97 - 16777215 - - - - <html><head/><body><p>Redo the UV mapping of ground planes to repeat in whole units across tile widths.</p></body></html> - - - - No change - - - - - 1x1 - - - - - 2x2 - - - - - 3x3 - - - - - - - - Ground Bitmap Key(s) - - - - - - - Separate multiple keys by spaces - - - - - - - - - - - - - 0 - 0 - - - - - 0 - 150 - - - - Qt::Vertical - - - 8 - - - false - - - - - 0 - 0 - - - - - 800 - 46 - - - - QFrame::StyledPanel - - - QAbstractItemView::NoEditTriggers - - - false - - - QAbstractItemView::SingleSelection - - - QAbstractItemView::SelectRows - - - Qt::DotLine - - - true - - - 5 - - - false - - - false - - - false - - - - - - - - - - - 0 - 0 - - - - - 858 - 40 - - - - - 16777215 - 40 - - - - Clean - - - - .. - - - - - - 0 - 0 - - - - - 800 - 48 - - - - Qt::ScrollBarAlwaysOff - - - QTextEdit::AutoAll - - - - - - - - - - - - - - - - - 0 - 0 - - - - - 138 - 26 - - - - Only decompile binary models, don't do fixes. - - - Decompile Only - - - - - - - - - toolBar - - - false - - - false - - - TopToolBarArea - - - false - - - - - - + - - 0 - 0 - 880 - 25 - + 00102422 - - File - + File + + View + + + - - Help - + Help + + + + - - - - :/icons/whats-this:/icons/whats-this - - - Help - - - Help - - - Context sensitive help - - - Context sensitive help - + + TopToolBarArea + + + + + + + + Load Preset + Ctrl+O - - - :/icons/save-preset:/icons/save-preset - - - Save Preset - - - Ctrl+S - - - - - - :/icons/load-preset:/icons/load-preset - - - Load Preset - - - Ctrl+O - + Save Preset + Ctrl+S - - Quit - - - Ctrl+Q - + Quit + Ctrl+Q + + + Help + F1 - - About - + About + + + Toggle Sidebar + Ctrl+Shift+S + + + Raw Log + Ctrl+L + + + Report Issue... - - - - onButtonReleased() - diff --git a/mainwindow_clean.cpp b/mainwindow_clean.cpp index e53b869..e801d73 100644 --- a/mainwindow_clean.cpp +++ b/mainwindow_clean.cpp @@ -1,144 +1,448 @@ -๏ปฟ#include "mainwindow.h" +๏ปฟ#include "constants.h" +#include "loghtml.h" +#include "mainwindow.h" #include "ui_mainwindow.h" #include +#include +#include +#include #include #include #include -using namespace std; - - void MainWindow::onCaptureCleanModelsOutput() { - if (m_pCleanProcess) + if (!m_pCleanProcess) + return; + + m_stdoutBuffer.append(m_pCleanProcess->readAllStandardOutput()); + + while (true) { - QString actionVerbPast = tr("Cleaned"); - QString actionVerbPresent = tr("Cleaning"); - QIcon actionIcon = m_iconCleaningMDL; - if (ui->decompileCheck->isChecked()) + int nlPos = m_stdoutBuffer.indexOf('\n'); + if (nlPos < 0) + break; + + QByteArray line = m_stdoutBuffer.left(nlPos).trimmed(); + m_stdoutBuffer.remove(0, nlPos + 1); + + if (line.isEmpty()) + continue; + + QJsonParseError jsonErr; + QJsonDocument doc = QJsonDocument::fromJson(line, &jsonErr); + if (jsonErr.error != QJsonParseError::NoError || !doc.isObject()) { - actionVerbPast = "Decompiled"; - actionVerbPresent = "Decompiling"; - actionIcon = m_iconDecompilingMDL; - } - auto outPut = QString::fromStdString(m_pCleanProcess->readAllStandardOutput().toStdString()); -#if (QT_VERSION >= QT_VERSION_CHECK(5, 15, 2)) - QStringList lines = outPut.split( "\n", Qt::SkipEmptyParts ); -#else - QStringList lines = outPut.split( "\n", QString::SkipEmptyParts ); -#endif - foreach( QString line, lines ) + appendDebugHtml("" % QString::fromUtf8(line).toHtmlEscaped() % "
    "); + continue; + } + + QJsonObject evt = doc.object(); + QString type = evt["type"].toString(); + QString file = evt["file"].toString(); + + bool decompiling = m_radioDecompile->isChecked(); + bool compiling = m_radioCompile->isChecked(); + QString actionVerbPast = compiling ? "Compiled" : decompiling ? "Decompiled" : "Cleaned"; + QString actionVerbPresent = compiling ? "Compiling" : decompiling ? "Decompiling" : "Cleaning"; + + if (type == "start") { - if (line == ".") - continue; - QRegExp rx_dot(R"(^((.)\2+)+$)"); - auto pos = rx_dot.indexIn(line); - if (pos > -1) - continue; - QString sStatus; - QString outputHtml; - QRegExp rx_reading("Attempting to read (.*)"); - pos = rx_reading.indexIn(line); - if (pos > -1) + m_cleanTimer.start(); + m_sCurrentModel = file; + m_pCleanStatus->setText("Processing " % file); + m_pStatusProgress->setVisible(true); + + int idx = evt["index"].toInt(); + int total = evt["total"].toInt(); + if (total > 0) { - m_cleanTimer.start(); - m_sCurrentModel = rx_reading.cap(1).trimmed(); - sStatus = tr("Reading ") % m_sCurrentModel; - m_pCleanStatus->setText(sStatus); - m_pStatusProgress->setVisible(true); - outputHtml = "

    " % line % "


    "; - ui->debugTextBrowser->insertHtml(outputHtml); - auto sb = ui->debugTextBrowser->verticalScrollBar(); - sb->setValue(sb->maximum()); - auto *twiReadingMDL = new QTableWidgetItem(); - twiReadingMDL->setIcon(m_iconReadingMDL); - twiReadingMDL->setToolTip("Reading"); - twiReadingMDL->setText(tr("Reading")); - ui->filesTable->setItem(findModelRow(m_sCurrentModel), 2, twiReadingMDL); - ui->filesTable->scrollToItem(twiReadingMDL); - continue; + m_pStatusProgress->setRange(0, total); + m_pStatusProgress->setValue(idx); } - QRegExp rx_mdl("MDL\\s(.*)\\sloaded."); - pos = rx_mdl.indexIn(line); - if (pos > -1) + + appendDebugHtml(LogHtml::logLine(LogColor::Info, + QStringLiteral("Processing ") % file.toHtmlEscaped() % QStringLiteral(""))); + + int row = findModelRow(file); + if (row >= 0) { - sStatus = tr(actionVerbPresent.toStdString().c_str()) % " " % m_sCurrentModel; - m_pCleanStatus->setText(sStatus); - m_pStatusProgress->setVisible(true); - outputHtml = "

    " % line % "


    "; - ui->debugTextBrowser->insertHtml(outputHtml); - auto sb = ui->debugTextBrowser->verticalScrollBar(); - sb->setValue(sb->maximum()); - auto *twiCleaningMDL = new QTableWidgetItem(); - twiCleaningMDL->setText(tr(actionVerbPresent.toStdString().c_str())); - twiCleaningMDL->setIcon(actionIcon); - twiCleaningMDL->setToolTip(tr(actionVerbPresent.toStdString().c_str())); - ui->filesTable->setItem(findModelRow(m_sCurrentModel), 2, twiCleaningMDL); - continue; + auto *item = new QTableWidgetItem(); + item->setText(QString::fromUtf8("โณ ") + actionVerbPresent); + item->setToolTip(actionVerbPresent); + m_filesTable->setItem(row, 2, item); + m_filesTable->scrollToItem(item); } - QRegExp rx_bin("Binary file (.*) detected, attempting import."); - pos = rx_bin.indexIn(line); - if (pos > -1) + } + else if (type == "done") + { + m_nMdlsCleaned++; + m_mdlsCleanedLabel->setText(actionVerbPast % ": " % QString::number(m_nMdlsCleaned)); + + int fixes = evt["fixes"].toInt(); + QString elapsedStr = QTime(0, 0).addMSecs(m_cleanTimer.elapsed()).toString("mm:ss.zzz"); + + QStringList fixDetails; + QStringList findings; + QStringList allTooltipLines; + QJsonObject result = evt["result"].toObject(); + QJsonArray checks = result["checks"].toArray(); + for (const auto &c : checks) { - sStatus = tr("Decompiling ") % rx_bin.cap(1); - m_pStatusProgress->setVisible(true); - m_pCleanStatus->setText(sStatus); + QJsonObject check = c.toObject(); + QString msg = check["message"].toString(); + if (check["fixed"].toBool()) + { + fixDetails << msg; + allTooltipLines << QString::fromUtf8("\xe2\x9c\x93 ") % msg; + } + else + { + QString sevLabel; + QJsonValue sevVal = check["severity"]; + if (sevVal.isString()) { + QString s = sevVal.toString(); + sevLabel = (s == "error" || s == "fatal") ? "ERROR" : s == "warning" ? "WARN" : "INFO"; + } else { + int sev = sevVal.toInt(); + sevLabel = sev >= 2 ? "ERROR" : sev == 1 ? "WARN" : "INFO"; + } + findings << QString("%1: %2").arg(sevLabel, msg); + allTooltipLines << QString("%1: %2").arg(sevLabel, msg); + } } - QRegExp rx_fixes(R"(Fixes made = (\d+))"); - pos = rx_fixes.indexIn(line); - if (pos > -1) + QJsonArray repairs = result["repairs"].toArray(); + for (const auto &r : repairs) { - auto *fixesItem = new QTableWidgetItem(rx_fixes.cap(1)); - fixesItem->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter); - ui->filesTable->setItem(findModelRow(m_sCurrentModel), 3, fixesItem); + fixDetails << r.toString(); + allTooltipLines << QString::fromUtf8("\xe2\x9c\x93 ") % r.toString(); } - QRegExp rx_written(R"((.*) written.)"); - pos = rx_written.indexIn(line); - if (pos > -1) + + appendDebugHtml(QStringLiteral("

    ") % LogHtml::span(LogColor::Success, + QStringLiteral("") % file.toHtmlEscaped() % QStringLiteral(" ") % actionVerbPast.toLower() + % QStringLiteral(" (") % QString::number(fixes) % QStringLiteral(" fixes)")) + % QStringLiteral("

    ")); + + QString detailHtml; + if (!fixDetails.isEmpty()) { - m_nMdlsCleaned++; - ui->mdlsCleanedLabel->setText("Files " % actionVerbPast % ": " % QString::number(m_nMdlsCleaned)); - outputHtml = "

    " % line % "


    "; - ui->debugTextBrowser->insertHtml(outputHtml); - auto sb = ui->debugTextBrowser->verticalScrollBar(); - sb->setValue(sb->maximum()); - auto *twiCleanSuccess = new QTableWidgetItem(); - twiCleanSuccess->setText(tr(actionVerbPast.toStdString().c_str())); - twiCleanSuccess->setIcon(m_iconCleanSuccess); - twiCleanSuccess->setToolTip(tr(actionVerbPast.toStdString().c_str())); - auto *twiCleanTimer = new QTableWidgetItem(); - twiCleanTimer->setText(QTime(0,0).addMSecs(m_cleanTimer.elapsed()).toString("mm:ss.zzz")); - twiCleanTimer->setTextAlignment(Qt::AlignCenter); - ui->filesTable->setItem(findModelRow(m_sCurrentModel), 2, twiCleanSuccess); - ui->filesTable->setItem(findModelRow(m_sCurrentModel), 4, twiCleanTimer); - continue; + detailHtml += "
      "; + for (const QString &d : fixDetails) + detailHtml += LogHtml::listItem(LogColor::FixApplied, d.toHtmlEscaped()); + detailHtml += "
    "; } - QRegExp rx_error(R"(\*\*\* Cannot(.*)|\*\* Load failed(.*))"); - pos = rx_error.indexIn(line); - if (pos > -1) + if (!findings.isEmpty()) { - m_nMdlsFailed++; - ui->mdlsFailedLabel->setText("Failures: " % QString::number(m_nMdlsFailed)); - outputHtml = "

    " % line % "


    "; - auto *twiCleanError = new QTableWidgetItem(); - twiCleanError->setText(tr("Failed")); - twiCleanError->setIcon(m_iconCleanError); - twiCleanError->setToolTip(tr("Failed")); - auto *twiCleanTimer = new QTableWidgetItem(); - twiCleanTimer->setText(QTime(0,0).addMSecs(m_cleanTimer.elapsed()).toString("mm:ss.zzz")); - twiCleanTimer->setTextAlignment(Qt::AlignCenter); - ui->filesTable->setItem(findModelRow(m_sCurrentModel), 2, twiCleanError); - ui->filesTable->setItem(findModelRow(m_sCurrentModel), 4, twiCleanTimer); + detailHtml += "
      "; + for (const QString &f : findings) + { + const char *color = f.startsWith("ERROR") ? LogColor::SevError + : f.startsWith("WARN") ? LogColor::SevWarning + : LogColor::SevInfo; + detailHtml += LogHtml::listItem(color, f.toHtmlEscaped()); + } + detailHtml += "
    "; } + if (!detailHtml.isEmpty()) + appendDebugHtml(detailHtml % "
    "); else + appendDebugHtml("
    "); + + // Store per-file results for the detail panel + QStringList fileHtmlLines; + fileHtmlLines << "

    " % file.toHtmlEscaped() % " โ€” " % actionVerbPast.toLower() % " (" % QString::number(fixes) % " fixes, " % elapsedStr % ")

    "; + if (!detailHtml.isEmpty()) + fileHtmlLines << detailHtml; + m_fileResults[file] = fileHtmlLines; + + int selectedRow = m_filesTable->currentRow(); + if (selectedRow >= 0 && selectedRow < m_filesTable->rowCount() + && m_filesTable->item(selectedRow, 0)->data(Qt::UserRole).toString() == file) + showFileDetails(file); + + int row = findModelRow(file); + if (row >= 0) + { + auto *statusItem = new QTableWidgetItem(); + statusItem->setText(QString::fromUtf8("โœ… ") + actionVerbPast); + statusItem->setToolTip(actionVerbPast); + m_filesTable->setItem(row, 2, statusItem); + + QString fixesText = QString::number(fixes); + if (!findings.isEmpty()) + fixesText += " (" % QString::number(findings.size()) % " info)"; + auto *fixesItem = new QTableWidgetItem(fixesText); + fixesItem->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter); + if (!allTooltipLines.isEmpty()) + fixesItem->setToolTip(allTooltipLines.join("\n")); + m_filesTable->setItem(row, 3, fixesItem); + + auto *timerItem = new QTableWidgetItem(elapsedStr); + timerItem->setTextAlignment(Qt::AlignCenter); + m_filesTable->setItem(row, 4, timerItem); + } + } + else if (type == "error") + { + m_nMdlsFailed++; + m_mdlsFailedLabel->setText("Failed: " % QString::number(m_nMdlsFailed)); + + QString msg = evt["message"].toString(); + QString elapsedStr = QTime(0, 0).addMSecs(m_cleanTimer.elapsed()).toString("mm:ss.zzz"); + + appendDebugHtml(LogHtml::logLine(LogColor::Error, + QStringLiteral("") % file.toHtmlEscaped() % QStringLiteral(": ") + % msg.toHtmlEscaped() % QStringLiteral(""))); + + QStringList errorHtml; + errorHtml << LogHtml::detailLine(LogColor::SevError, + QStringLiteral("") % file.toHtmlEscaped() % QStringLiteral(" โ€” Error")); + errorHtml << "

    " % msg.toHtmlEscaped() % "

    "; + m_fileResults[file] = errorHtml; + + int selectedRow = m_filesTable->currentRow(); + if (selectedRow >= 0 && selectedRow < m_filesTable->rowCount() + && m_filesTable->item(selectedRow, 0)->data(Qt::UserRole).toString() == file) + showFileDetails(file); + + int row = findModelRow(file); + if (row >= 0) { - outputHtml = "" % line % "
    "; + auto *item = new QTableWidgetItem(); + item->setText(QString::fromUtf8("โŒ Failed")); + item->setToolTip(msg); + m_filesTable->setItem(row, 2, item); + + auto *timerItem = new QTableWidgetItem(elapsedStr); + timerItem->setTextAlignment(Qt::AlignCenter); + m_filesTable->setItem(row, 4, timerItem); } - ui->debugTextBrowser->insertHtml(outputHtml); - auto sb = ui->debugTextBrowser->verticalScrollBar(); - sb->setValue(sb->maximum()); } + else if (type == "summary") + { + QString msg = evt["message"].toString(); + appendDebugHtml("

    " % msg.toHtmlEscaped() % "


    "); + } + } +} + +QStringList MainWindow::buildCliArgs() +{ + QStringList args; + + if (m_radioDecompile->isChecked()) + { + args << CliCommand::Decompile << CliFlag::JsonLines; + } + else if (m_radioCompile->isChecked()) + { + args << CliCommand::Compile << CliFlag::JsonLines; + } + else + { + args << CliCommand::Repair << CliFlag::JsonLines; + + // Validation checks + if (m_checkValidateAll->isChecked()) { + args << CliFlag::Check; + } else if (!m_individualChecks.isEmpty()) { + QStringList selected = selectedCheckNames(); + int total = totalCheckCount(); + if (!selected.isEmpty() && selected.size() < total) { + args << CliFlag::Check; + int unselected = total - selected.size(); + if (unselected < selected.size()) { + QStringList excluded; + for (auto it = m_individualChecks.constBegin(); it != m_individualChecks.constEnd(); ++it) { + if (!selected.contains(it.key())) + excluded << it.key(); + } + args << CliFlag::ExcludeChecks << excluded.join(","); + } else { + args << CliFlag::IncludeChecks << selected.join(","); + } + } else if (selected.size() == total) { + args << CliFlag::Check; + } + } + + // Individual fix flags + if (m_checkStripDegen->isChecked()) + args << CliFlag::StripDegenerate; + if (m_checkFixAnims->isChecked()) + args << CliFlag::FixAnimations; + if (m_checkRepairPivots->isChecked()) + args << CliFlag::FixPivots; + if (m_checkFixTilefade->isChecked()) + { + args << CliFlag::FixTilefade; + double h = m_sliceHeightSpin->value() * 0.1; + if (qAbs(h - 5.0) > 0.01) + args << CliFlag::TilefadeZ << QString::number(h, 'g', 6); + } + if (m_checkTilefadeUndo->isChecked()) + args << CliFlag::TilefadeUndo; + if (m_checkRebuildAABB->isChecked()) + args << CliFlag::FixAabb; + if (m_checkReparentChildren->isChecked()) + args << CliFlag::ReparentChildren; + if (m_checkWrapRoot->isChecked()) + args << CliFlag::WrapRoot; + if (m_checkSplitMultiEdge->isChecked()) + args << CliFlag::SplitMultiedge; + + // Per-axis scaling + double sx = m_scaleXSpin->value(), sy = m_scaleYSpin->value(), sz = m_scaleZSpin->value(); + bool allSame = qAbs(sx - sy) < 0.001 && qAbs(sy - sz) < 0.001; + if (allSame && qAbs(sx - 1.0) > 0.001) + { + args << CliFlag::Scale << QString::number(sx, 'g', 6); + } + else + { + if (qAbs(sx - 1.0) > 0.001) + args << CliFlag::ScaleX << QString::number(sx, 'g', 6); + if (qAbs(sy - 1.0) > 0.001) + args << CliFlag::ScaleY << QString::number(sy, 'g', 6); + if (qAbs(sz - 1.0) > 0.001) + args << CliFlag::ScaleZ << QString::number(sz, 'g', 6); + } + + // Classification override + QString classVal = m_classificationCombo->currentData().toString(); + if (!classVal.isEmpty()) + args << CliFlag::Classification << classVal; + + // Snap + QString snapVal = m_snapCombo->currentData().toString(); + if (!snapVal.isEmpty()) + args << CliFlag::Snap << snapVal; + + // TVert snap + QString tvertVal = m_tvertSnapCombo->currentData().toString(); + if (!tvertVal.isEmpty()) + args << CliFlag::TvertSnap << tvertVal; + + // Render override + QString renderVal = m_renderCombo->currentData().toString(); + if (!renderVal.isEmpty()) + args << CliFlag::Render << renderVal; + + // Shadow override + QString shadowVal = m_shadowCombo->currentData().toString(); + if (!shadowVal.isEmpty()) + args << CliFlag::Shadow << shadowVal; + + // Mesh operations + if (m_forceWhiteCheck->isChecked()) + args << CliFlag::ForceWhite; + if (m_mergeByBitmapCheck->isChecked()) + args << CliFlag::MergeByBitmap; + if (m_cullInvisibleCheck->isChecked()) + args << CliFlag::CullInvisible; + if (m_standardizeTexture0Check->isChecked()) + args << CliFlag::StandardizeTexture0; + if (m_stripEEExtrasCheck->isChecked()) + args << CliFlag::StripEEExtras; + + // Placeable transparency + if (m_placeableTransCheck->isChecked()) + { + args << CliFlag::PlaceableTrans; + if (!m_transparencyKeyEdit->text().isEmpty()) + args << CliFlag::TransparencyKey << m_transparencyKeyEdit->text(); + } + + // Pivot sub-options + if (m_checkRepairPivots->isChecked()) + { + if (m_pivotAllowSplitCheck->isChecked()) + args << CliFlag::PivotAllowSplit; + + QString belowZ0 = m_pivotBelowZ0Combo->currentData().toString(); + if (m_pivotBelowZ0Combo->currentIndex() > 0) + args << CliFlag::PivotBelowZ0 << belowZ0; + + QString moveBad = m_pivotMoveBadCombo->currentData().toString(); + if (m_pivotMoveBadCombo->currentIndex() > 0) + args << CliFlag::PivotMoveBad << moveBad; + + QString smoothing = m_pivotSmoothingCombo->currentData().toString(); + if (m_pivotSmoothingCombo->currentIndex() > 0) + args << CliFlag::PivotSmoothing << smoothing; + + args << CliFlag::PivotMinFaces << QString::number(m_pivotMinFacesSpin->value()); + + QString splitFirst = m_pivotSplitFirstCombo->currentData().toString(); + if (m_pivotSplitFirstCombo->currentIndex() > 0) + args << CliFlag::PivotSplitFirst << splitFirst; + } + + // Tile options + if (m_waterEnableCheck->isChecked()) + { + args << CliFlag::Water; + if (!m_waterKeyEdit->text().isEmpty()) + args << CliFlag::WaterKey << m_waterKeyEdit->text(); + QString dynVal = m_dynamicWaterCombo->currentData().toString(); + args << CliFlag::DynamicWater << dynVal; + if (dynVal == "wavy") + args << CliFlag::WaveHeight << QString::number(m_waveHeightSpin->value(), 'g', 4); + QString rotW = m_rotateWaterCombo->currentData().toString(); + if (!rotW.isEmpty()) + args << CliFlag::RotateWater << rotW; + QString retileW = m_retileWaterCombo->currentData().toString(); + if (!retileW.isEmpty()) + args << CliFlag::RetileWater << retileW; + } + + QString foliageVal = m_foliageCombo->currentData().toString(); + if (!foliageVal.isEmpty()) + { + args << CliFlag::Foliage << foliageVal; + if (foliageVal != "ignore" && !m_foliageKeyEdit->text().isEmpty()) + args << CliFlag::FoliageKey << m_foliageKeyEdit->text(); + } + + if (m_animateSplotchesCheck->isChecked()) + { + args << CliFlag::Splotch << "animate"; + if (!m_splotchKeyEdit->text().isEmpty()) + args << CliFlag::SplotchKey << m_splotchKeyEdit->text(); + } + + QString rotGround = m_rotateGroundCombo->currentData().toString(); + if (!rotGround.isEmpty()) + { + args << CliFlag::RotateGround << rotGround; + if (!m_groundKeyEdit->text().isEmpty()) + args << CliFlag::GroundKey << m_groundKeyEdit->text(); + } + + QString chamferVal = m_chamferCombo->currentData().toString(); + if (!chamferVal.isEmpty()) + args << CliFlag::Chamfer << chamferVal; + + QString retileG = m_retileGroundCombo->currentData().toString(); + if (!retileG.isEmpty()) + args << CliFlag::RetileGround << retileG; + + QString raiseLowerVal = m_raiseLowerCombo->currentData().toString(); + if (!raiseLowerVal.isEmpty()) + { + args << CliFlag::RaiseLower << raiseLowerVal; + args << CliFlag::RaiseAmount << QString::number(m_raiseAmountSpin->value(), 'g', 4); + } + + if (m_remapWokMatCheck->isChecked()) + args << CliFlag::RemapWalkmesh << QString("%1:%2").arg(m_wokMatFromSpin->value()).arg(m_wokMatToSpin->value()); } + + args << m_sInDir; + if (!m_sOutDir.isEmpty() && m_sOutDir != m_sInDir) + args << m_sOutDir; + + return args; } void MainWindow::doClean() @@ -146,81 +450,161 @@ void MainWindow::doClean() if (m_bCleanRunning) { m_pCleanProcess->kill(); - ui->debugTextBrowser->append(tr("Aborted")); - ui->decompileCheck->setEnabled(true); + m_debugTextBrowser->append("Aborted"); auto *twiCleanAborted = new QTableWidgetItem(); - twiCleanAborted->setText(tr("Aborted")); + twiCleanAborted->setText("Aborted"); twiCleanAborted->setIcon(m_iconAbortButton); - twiCleanAborted->setToolTip(tr("Aborted")); - ui->filesTable->setItem(findModelRow(m_sCurrentModel), 2, twiCleanAborted); + int row = findModelRow(m_sCurrentModel); + if (row >= 0) + m_filesTable->setItem(row, 2, twiCleanAborted); return; } - connect(m_pCleanProcess, SIGNAL(readyReadStandardOutput()), this, SLOT(onCaptureCleanModelsOutput())); - ui->debugTextBrowser->clear(); - ui->debugTextBrowser->insertHtml(tr("Running cleanmodels
    ")); - QStringList args; - m_pCleanProcess->setCurrentWriteChannel(QProcess::StandardOutput); + + disconnect(m_pCleanProcess, &QProcess::readyReadStandardOutput, + this, &MainWindow::onCaptureCleanModelsOutput); + connect(m_pCleanProcess, &QProcess::readyReadStandardOutput, + this, &MainWindow::onCaptureCleanModelsOutput); + + m_stdoutBuffer.clear(); + m_debugTextBrowser->clear(); + m_debugTextBrowser->insertHtml("Running cleanmodels
    "); + m_fileResults.clear(); + m_batchSummary.clear(); + m_detailPanel->clear(); + m_lastFailedFiles.clear(); + m_lastErrorOutput.clear(); + + if (!m_rawLogVisible) + toggleRawLog(); + + QStringList args = buildCliArgs(); + m_lastCommand = args.join(" "); + + appendDebugHtml(LogHtml::span(LogColor::Command, + QStringLiteral("$ ") % m_sBinaryPath.toHtmlEscaped() % QStringLiteral(" ") + % args.join(" ").toHtmlEscaped()) % QStringLiteral("
    ")); + + // Async start: kick off the process, disable the button, and let + // onCleanStarted / onCleanProcessError swap in the running or failed UI + // when QProcess emits its signals. This keeps the UI thread responsive + // even when binary lookup or fork/exec is slow. m_pCleanProcess->setWorkingDirectory(QDir::currentPath()); - QString filePattern = ui->filePattern->text(); - if (ui->decompileCheck->isChecked()) - args<<"-d"; - else - args<<"last_dirs.pl"; - m_pCleanProcess->start(m_sBinaryPath,args,QIODevice::ReadWrite); - ui->cleanButton->setDisabled(true); - if (m_pCleanProcess->waitForStarted()) - { - ui->decompileCheck->setEnabled(false); - m_nMdlsCleaned = 0; - m_nMdlsFailed = 0; - ui->mdlsCleanedLabel->setText("Files Cleaned: 0"); - ui->mdlsFailedLabel->setText("Failures: 0"); - m_bCleanRunning = true; - ui->cleanButton->setDisabled(false); - ui->cleanButton->setText(tr("Abort")); - ui->cleanButton->setIcon(m_iconAbortButton); - m_pCleanProcess->moveToThread(QCoreApplication::instance()->thread()); - } - else - { - QString errorMsg = "

    Failed to run clean! Does the " % m_sBinaryName % " executable exist in the working directory or your PATH?


    " % m_sBinaryPath; - ui->debugTextBrowser->insertHtml(tr(errorMsg.toStdString().c_str())); - auto sb = ui->debugTextBrowser->verticalScrollBar(); - sb->setValue(sb->maximum()); - ui->cleanButton->setDisabled(false); - } + m_cleanButton->setDisabled(true); + m_pCleanProcess->start(m_sBinaryPath, args, QIODevice::ReadOnly); } -void MainWindow::onCleanFinished(int, QProcess::ExitStatus) +void MainWindow::onCleanStarted() { - ui->debugTextBrowser->append(m_pCleanProcess->readAllStandardError()); + m_nMdlsCleaned = 0; + m_nMdlsFailed = 0; + m_mdlsCleanedLabel->setText("Cleaned: 0"); + m_mdlsFailedLabel->setText("Failed: 0"); + m_bCleanRunning = true; + m_cleanButton->setDisabled(false); + m_cleanButton->setText("Abort"); + m_cleanButton->setIcon(m_iconAbortButton); + m_cleanButton->setStyleSheet(QStringLiteral("QPushButton { color: ") + % QLatin1String(LogColor::Error) + % QStringLiteral("; font-weight: bold; }")); + m_pStatusProgress->setRange(0, 0); + m_pStatusProgress->setVisible(true); +} + +void MainWindow::onCleanProcessError(QProcess::ProcessError err) +{ + // Runtime errors (Crashed, Timedout, ReadError, WriteError) are reflected + // in onCleanFinished's ExitStatus. Only FailedToStart fires before + // onCleanStarted and is not surfaced anywhere else. + if (err != QProcess::FailedToStart || m_bCleanRunning) + return; + + QString errorMsg = LogHtml::logLine(LogColor::Error, + QStringLiteral("Failed to run cleanmodels! Does the ") % m_sBinaryName.toHtmlEscaped() + % QStringLiteral(" executable exist in the working directory or your PATH?")); + appendDebugHtml(errorMsg); + m_detailPanel->setHtml(LogHtml::detailLine(LogColor::SevError, + QStringLiteral("Failed to start cleanmodels.
    Ensure the ") + % m_sBinaryName.toHtmlEscaped() + % QStringLiteral(" executable is in your PATH or alongside this application."))); + m_cleanButton->setDisabled(false); +} + +void MainWindow::onCleanFinished(int exitCode, QProcess::ExitStatus exitStatus) +{ + onCaptureCleanModelsOutput(); + + QString stderrOutput = QString::fromUtf8(m_pCleanProcess->readAllStandardError()).trimmed(); + if (!stderrOutput.isEmpty()) + m_debugTextBrowser->append(stderrOutput); + m_bCleanRunning = false; - if (!ui->decompileCheck->isChecked()) - ui->cleanButton->setText(tr("Clean")); - else - ui->cleanButton->setText(tr("Decompile")); - ui->decompileCheck->setEnabled(true); - ui->cleanButton->setIcon(m_iconCleanButton); - m_pCleanStatus->setText(tr("Idle")); + + updateModeUI(); + m_cleanButton->setStyleSheet(""); + m_pCleanStatus->setText("Idle"); m_pStatusProgress->setVisible(false); - if(m_bUpdateFilesAfterClean) + + const QString reportHint = "

    Right-click a file or use Help > Report Issue to submit a bug report.

    "; + + if (exitStatus == QProcess::CrashExit) { + m_lastErrorOutput = "cleanmodels crashed"; + if (!stderrOutput.isEmpty()) + m_lastErrorOutput += ": " + stderrOutput; + m_lastFailedFiles = collectTableFilePaths(true); + + QString msg = LogHtml::detailLine(LogColor::SevError, + QStringLiteral("cleanmodels crashed.")); + if (!stderrOutput.isEmpty()) + msg += LogHtml::preBlock(LogColor::SevError, stderrOutput.toHtmlEscaped()); + msg += reportHint; + m_detailPanel->setHtml(msg); + appendDebugHtml(msg); + } else if (exitCode != 0 && m_nMdlsCleaned == 0 && m_nMdlsFailed == 0) { + m_lastErrorOutput = "exit code " + QString::number(exitCode); + if (!stderrOutput.isEmpty()) + m_lastErrorOutput += ": " + stderrOutput; + m_lastFailedFiles = collectTableFilePaths(true); + + QString msg = LogHtml::detailLine(LogColor::SevError, + QStringLiteral("cleanmodels exited with error code ") + % QString::number(exitCode) % QStringLiteral("")); + if (!stderrOutput.isEmpty()) + msg += LogHtml::preBlock(LogColor::SevError, stderrOutput.toHtmlEscaped()); + else + msg += "

    No output was produced. Check the CLI flags and try running from a terminal.

    "; + msg += reportHint; + m_detailPanel->setHtml(msg); + appendDebugHtml(msg); + } + + m_batchSummary = QString("%1 files cleaned, %2 failed") + .arg(m_nMdlsCleaned) + .arg(m_nMdlsFailed); + + if (m_filesTable->currentRow() < 0) + showBatchSummary(); + + if (m_bUpdateFilesAfterClean) { - MainWindow::updateFileListing(); + updateFileListing(); m_bUpdateFilesAfterClean = false; } } -int MainWindow::findModelRow(const QString& mdlFile) +int MainWindow::findModelRow(const QString &mdlFile) { - int rows = ui->filesTable->rowCount(); - int foundRow = 0; // fall back to first row if we can't find it for some reason + int rows = m_filesTable->rowCount(); for (int i = 0; i < rows; ++i) { - if (ui->filesTable->item(i, 0)->text() == mdlFile) - { - foundRow = i; - break; - } + if (m_filesTable->item(i, 0)->data(Qt::UserRole).toString() == mdlFile) + return i; } - return foundRow; + return -1; +} + +void MainWindow::appendDebugHtml(const QString &html) +{ + m_debugTextBrowser->insertHtml(html); + auto sb = m_debugTextBrowser->verticalScrollBar(); + sb->setValue(sb->maximum()); } diff --git a/mdlanimationplayer.cpp b/mdlanimationplayer.cpp new file mode 100644 index 0000000..79b0203 --- /dev/null +++ b/mdlanimationplayer.cpp @@ -0,0 +1,271 @@ +#include "mdlanimationplayer.h" + +#include "mdlscene.h" + +#include +#include +#include +#include + +void MdlAnimationPlayer::setScene(const MdlScene *scene) +{ + m_scene = scene; + stop(); +} + +QStringList MdlAnimationPlayer::animationNames() const +{ + return m_scene ? m_scene->animationNames() : QStringList{}; +} + +float MdlAnimationPlayer::currentLength() const +{ + return m_currentAnim ? m_currentAnim->length : 0.0f; +} + +bool MdlAnimationPlayer::play(const QString &name) +{ + if (!m_scene) + return false; + const MdlAnimation *anim = m_scene->findAnimation(name); + if (!anim) + return false; + m_currentAnim = anim; + m_currentName = anim->name; + m_time = 0.0f; + m_state = State::Playing; + recomputeAnimatedTransforms(); + recomputeBoneMatrices(); + return true; +} + +QString MdlAnimationPlayer::playPreferred(const QStringList &preferred) +{ + for (const QString &n : preferred) { + if (play(n)) + return m_currentName; + } + return {}; +} + +void MdlAnimationPlayer::stop() +{ + m_state = State::Stopped; + m_time = 0.0f; + m_currentAnim = nullptr; + m_currentName.clear(); + m_animLocal.clear(); + if (!m_scene) { + m_boneWorld.clear(); + return; + } + // Always recompute against the current scene. An earlier version + // tried to skip this when state was already Stopped, but + // setScene(newScene) -> stop() would then leave m_boneWorld keyed to + // the *previous* scene's indices, and a subsequent loadModel of any + // model without a preferred-idle animation would render with the + // old model's bone transforms. The walk is O(N) over a small N + // (typical creature: ~50 nodes) so the optimization was not worth + // the correctness foot-gun. + recomputeBoneMatrices(); +} + +void MdlAnimationPlayer::pause() +{ + if (m_state == State::Playing) + m_state = State::Paused; +} + +void MdlAnimationPlayer::resume() +{ + if (m_state == State::Paused) + m_state = State::Playing; +} + +bool MdlAnimationPlayer::update(float dt) +{ + if (m_state != State::Playing || !m_currentAnim || !m_scene) + return false; + + const float length = m_currentAnim->length; + m_time += dt * m_speed; + if (length > 0.0f) { + if (m_time >= length) { + if (m_looping) { + m_time = std::fmod(m_time, length); + } else { + m_time = length; + m_state = State::Stopped; + } + } + } else { + // Static animation (length = 0): just sample at t=0. + m_time = 0.0f; + } + + recomputeAnimatedTransforms(); + recomputeBoneMatrices(); + return true; +} + +namespace { + +// Find the keyframe pair `[i0, i1]` that brackets `t` in the sorted `times` +// vector, plus the lerp alpha in [0,1]. For `t` outside the range both +// indices clamp to the same endpoint and alpha is 0. `times` is asserted +// non-empty by the caller. +struct KeyBracket { int i0; int i1; float alpha; }; +KeyBracket bracket(const QVector ×, float t) +{ + if (times.isEmpty()) + return {0, 0, 0.0f}; + const int last = static_cast(times.size()) - 1; + if (t <= times.first()) + return {0, 0, 0.0f}; + if (t >= times.last()) + return {last, last, 0.0f}; + // upper_bound returns the first element strictly greater than t. Since + // we already handled t <= first and t >= last, the result is in + // (begin, end) and (it - 1) is the keyframe at-or-before t. + auto it = std::upper_bound(times.constBegin(), times.constEnd(), t); + int i1 = static_cast(it - times.constBegin()); + int i0 = i1 - 1; + float span = times[i1] - times[i0]; + float a = span > 0.0f ? (t - times[i0]) / span : 0.0f; + return {i0, i1, a}; +} + +QVector3D lerpVec3(const QVector3D &a, const QVector3D &b, float t) +{ + return a * (1.0f - t) + b * t; +} + +float lerpFloat(float a, float b, float t) +{ + return a * (1.0f - t) + b * t; +} + +} // namespace + +void MdlAnimationPlayer::recomputeAnimatedTransforms() +{ + m_animLocal.clear(); + if (!m_currentAnim) + return; + + const float t = m_time; + for (auto it = m_currentAnim->channels.constBegin(); + it != m_currentAnim->channels.constEnd(); ++it) + { + const QString &name = it.key(); + const MdlAnimNodeChannels &ch = it.value(); + AnimatedLocal local; + + if (!ch.posValues.isEmpty()) { + KeyBracket b = bracket(ch.posTimes, t); + local.position = (b.i0 == b.i1) + ? ch.posValues[b.i0] + : lerpVec3(ch.posValues[b.i0], ch.posValues[b.i1], b.alpha); + local.hasPos = true; + } + if (!ch.oriValues.isEmpty()) { + KeyBracket b = bracket(ch.oriTimes, t); + local.orientation = (b.i0 == b.i1) + ? ch.oriValues[b.i0] + : QQuaternion::slerp(ch.oriValues[b.i0], + ch.oriValues[b.i1], b.alpha); + local.hasOri = true; + } + if (!ch.sclValues.isEmpty()) { + KeyBracket b = bracket(ch.sclTimes, t); + local.scale = (b.i0 == b.i1) + ? ch.sclValues[b.i0] + : lerpFloat(ch.sclValues[b.i0], ch.sclValues[b.i1], b.alpha); + local.hasScl = true; + } + + if (local.hasPos || local.hasOri || local.hasScl) + m_animLocal.insert(name, local); + } +} + +void MdlAnimationPlayer::recomputeBoneMatrices() +{ + m_boneWorld.clear(); + if (!m_scene) + return; + + const QVector &nodes = m_scene->nodes(); + + // Per-node animated local transform, computed once. We need this twice + // (once when the node is the target, once when it appears as someone + // else's ancestor) so caching it locally avoids re-doing the + // makeLocalTransform + animLocal lookup for every descendant. + QVector locals(nodes.size()); + for (int i = 0; i < nodes.size(); ++i) { + const MdlNode &n = nodes[i]; + QVector3D pos = n.position; + QQuaternion ori = n.orientation; + float scl = n.scale; + auto al = m_animLocal.constFind(n.name); + if (al != m_animLocal.constEnd()) { + if (al.value().hasPos) pos = al.value().position; + if (al.value().hasOri) ori = al.value().orientation; + if (al.value().hasScl) scl = al.value().scale; + } + locals[i] = makeLocalTransform(pos, ori, scl); + } + + // Iterative chain-then-compose. The shape resembles + // MdlScene::bindWorldTransformOf (also a parent-walk with cycle + // guard) but this version also memoizes via m_boneWorld so a + // second visit short-circuits โ€” important for deeply nested + // skeletons where without the cache we'd redo most ancestor + // composes once per descendant. For each node we walk up to the + // root (or until we hit a node whose world matrix is already + // cached), collecting indices in a chain. Then we multiply + // locals from the closest known ancestor down to the node, + // caching each intermediate result. A QSet guards against + // cyclic node.parent links โ€” + // a malformed MDL otherwise blows the stack at 60 Hz when the + // renderer pulls boneWorldMatrices() each tick. + QSet visited; + QVector chain; + chain.reserve(16); + for (int start = 0; start < nodes.size(); ++start) { + // The inner cache-hit on m_boneWorld at the top of the parent + // walk handles already-computed nodes for free: the chain + // exits empty and the compose loop below is a no-op. One + // predicate, evaluated lazily inside the walk, instead of two + // (here and there). + chain.clear(); + visited.clear(); + int cur = start; + QMatrix4x4 ancestor; + bool haveAncestor = false; + while (cur >= 0 && cur < nodes.size()) { + if (visited.contains(cur)) + break; // cycle: treat as root + visited.insert(cur); + auto cached = m_boneWorld.constFind(cur); + if (cached != m_boneWorld.constEnd()) { + ancestor = cached.value(); + haveAncestor = true; + break; + } + chain.append(cur); + int parentIdx = m_scene->findNodeByName(nodes[cur].parent); + if (parentIdx == cur) + break; // self-parent: treat as root + cur = parentIdx; + } + + // Compose from the (known) ancestor downward. + QMatrix4x4 acc = haveAncestor ? ancestor : QMatrix4x4{}; + for (int i = chain.size() - 1; i >= 0; --i) { + const int idx = chain[i]; + acc = acc * locals[idx]; + m_boneWorld.insert(idx, acc); + } + } +} diff --git a/mdlanimationplayer.h b/mdlanimationplayer.h new file mode 100644 index 0000000..4de13da --- /dev/null +++ b/mdlanimationplayer.h @@ -0,0 +1,92 @@ +#ifndef MDLANIMATIONPLAYER_H +#define MDLANIMATIONPLAYER_H + +#include +#include +#include +#include + +class MdlScene; +struct MdlAnimation; + +// Plays an MdlAnimation against an MdlScene's bind hierarchy and exposes +// per-bone world matrices for skinning, mirroring the design in +// borealis_nwn_mdl's AnimationPlayer (no shared code, but the same API +// shape: setScene/play/update/bone matrices). +// +// The scene itself is never mutated. Instead we maintain a parallel +// "animated transform" map per bone, then walk the bind hierarchy using +// animated transforms where present and bind transforms elsewhere. +class MdlAnimationPlayer +{ +public: + enum class State { Stopped, Playing, Paused }; + + void setScene(const MdlScene *scene); + + QStringList animationNames() const; + + bool play(const QString &name); + // Try names in order; play the first one that exists. Returns the + // chosen name (or empty if none matched). Useful for "cpause1, cstand, + // pause1, stand" fallback chains. + QString playPreferred(const QStringList &preferred); + + void stop(); + void pause(); + void resume(); + + void setLooping(bool b) { m_looping = b; } + bool isLooping() const { return m_looping; } + + void setSpeed(float s) { m_speed = s; } + float speed() const { return m_speed; } + + State state() const { return m_state; } + float currentTime() const { return m_time; } + float currentLength() const; + QString currentName() const { return m_currentName; } + + // Advance time by dt seconds (handles looping). Returns true if any + // animated transform changed since the last call (always true while + // playing; false while stopped/paused). + bool update(float dt); + + // World-space animated transform per node, keyed by node index in + // MdlScene::nodes(). Index keys (rather than names) avoid duplicate- + // name aliasing in malformed MDLs and are O(1) for the renderer to + // look up. Falls back to identity for nodes the player never visited + // (no scene set, or a node unreachable from the parent walk). + const QHash &boneWorldMatrices() const { return m_boneWorld; } + +private: + void recomputeAnimatedTransforms(); + void recomputeBoneMatrices(); + + const MdlScene *m_scene = nullptr; + const MdlAnimation *m_currentAnim = nullptr; + QString m_currentName; + State m_state = State::Stopped; + float m_time = 0.0f; + float m_speed = 1.0f; + bool m_looping = true; + + // Per-bone animated local transform (position/orientation/scale) for the + // current m_time. Bones absent from the map fall back to bind values. + struct AnimatedLocal { + QVector3D position; + QQuaternion orientation; + float scale = 1.0f; + bool hasPos = false; + bool hasOri = false; + bool hasScl = false; + }; + QHash m_animLocal; + + // World-space transform per node, computed by walking the bind + // hierarchy with animated locals overriding bind values. Keyed by + // node index (stable, unique even with duplicate node names). + QHash m_boneWorld; +}; + +#endif diff --git a/mdlscene.cpp b/mdlscene.cpp new file mode 100644 index 0000000..4c3fbcc --- /dev/null +++ b/mdlscene.cpp @@ -0,0 +1,974 @@ +#include "mdlscene.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// Cap on parser arrays โ€” applies uniformly to verts/faces/normals/tverts/ +// weights and to every per-channel keyframe list. Without this a small +// crafted MDL can OOM the process by claiming millions of keyframes per +// channel; 10M keys per channel is ~4 orders of magnitude above NWN's +// realistic worst case (~50 keys/channel) so this only ever rejects abuse. +constexpr int kMaxArraySize = 10'000'000; + +// Cap on number of animations + per-animation channels. Same rationale. +constexpr int kMaxAnimations = 10'000; + +// Hard cap on total input size to loadFromString. QString uses 16-bit +// QChar internally, so 32M chars = 64 MB of QString memory โ‰ˆ 32 MB of +// ASCII source on disk. Real ASCII MDLs are 1โ€“5 MB; the largest +// legitimate tile model I've measured is ~20 MB. 32 MB ASCII is +// generous for a single MDL and keeps a 2 GB attacker file from +// becoming a 4 GB QString plus a 4 GB+ QStringList just to be told +// it's malformed. Hits feed a loadWarning and loadFromString returns +// false (parsed scene is empty). +constexpr qsizetype kMaxInputChars = 32 * 1024 * 1024; + +// Scene-wide aggregate caps on geometry. The per-array cap above +// (kMaxArraySize) bounds any single `verts N` / `faces N` +// declaration, but their product across all nodes is unbounded: +// kMaxNodes * kMaxArraySize verts โ‰ˆ 4ยท10^10 entries โ‰ˆ 491 TB. A +// crafted MDL with, say, 50 trimesh nodes each declaring 1M verts +// passes every per-node check and still claims 600 MB+ of vert +// storage alone. These caps bound the running scene total. 16M is +// ~256ร— the largest legitimate BioWare tile model; budget cost at +// the cap is ~192 MB verts + ~512 MB faces, which is generous but +// still survivable on every developer machine cleanmodels-qt +// targets. Hits feed a coalesced loadWarning so the user can tell +// a partial scene from a clean load. +constexpr qint64 kMaxTotalVerts = 16'000'000; +constexpr qint64 kMaxTotalFaces = 16'000'000; +constexpr int kMaxChannelsPerAnim = 100'000; + +// Cap on parser node-nesting depth. NWN's real hierarchies bottom out +// around 20 levels; 256 leaves plenty of headroom while keeping a small +// crafted MDL from blowing the parser's recursion stack. +constexpr int kMaxNodeDepth = 256; + +// Cap on total node count per scene. Real BioWare content tops out +// well under 1000 nodes per MDL (creatures ~30-100, tiles ~200-500, +// items <20). 4096 is comfortably above any realistic case while +// bounding the worst-case cost of the m_childrenByParent build at +// load time: a duplicate-name attack is still O(Nยฒ) entries inside +// the cap (16M ints, ~64 MB โ€” slow, but not catastrophic), and a +// pathological MDL claiming millions of nodes is rejected outright +// rather than spending the load consuming memory before any render +// can happen. +constexpr int kMaxNodes = 4096; + +// Decode the `axis_x axis_y axis_z angle_radians` quad starting at +// `tokens[offset]` into a Qt quaternion. Returns identity on bad input. +// Centralizes the "NWN stores radians, Qt wants degrees" conversion that +// was duplicated across the static and keyframe orientation parsers. +QQuaternion parseAxisAngleQuat(const QStringList &tokens, int offset) +{ + if (tokens.size() < offset + 4) + return {}; + return QQuaternion::fromAxisAndAngle( + QVector3D(tokens[offset].toFloat(), + tokens[offset + 1].toFloat(), + tokens[offset + 2].toFloat()), + qRadiansToDegrees(tokens[offset + 3].toFloat())); +} + +// Append a position keyframe `(t, x, y, z)` parsed from `tokens` starting +// at `offset`. Drops silently on undersized input or when the channel has +// already hit the per-channel cap (parser DoS guard). +void appendPosKey(MdlAnimNodeChannels &ch, float t, + const QStringList &tokens, int offset) +{ + if (tokens.size() < offset + 3) + return; + if (ch.posTimes.size() >= kMaxArraySize) + return; + ch.posTimes.append(t); + ch.posValues.append(QVector3D(tokens[offset].toFloat(), + tokens[offset + 1].toFloat(), + tokens[offset + 2].toFloat())); +} + +void appendOriKey(MdlAnimNodeChannels &ch, float t, + const QStringList &tokens, int offset) +{ + if (tokens.size() < offset + 4) + return; + if (ch.oriTimes.size() >= kMaxArraySize) + return; + ch.oriTimes.append(t); + ch.oriValues.append(parseAxisAngleQuat(tokens, offset)); +} + +void appendSclKey(MdlAnimNodeChannels &ch, float t, + const QStringList &tokens, int offset) +{ + if (tokens.size() < offset + 1) + return; + if (ch.sclTimes.size() >= kMaxArraySize) + return; + ch.sclTimes.append(t); + ch.sclValues.append(tokens[offset].toFloat()); +} + +// Advance `pos` past the next line whose trimmed form is exactly +// `terminator`. Used by the parser short-circuit paths to fast-forward +// out of a block whose contents we've decided not to keep. Strict +// equality (not startsWith) is intentional: a corrupted line like +// "endnode_garbage" must not be treated as a real `endnode` or the +// parser desyncs and starts consuming the next block as data. +// +// IMPORTANT: this is a flat, first-match skipper. It does NOT track +// nested block structure. Use it only when the block being skipped +// cannot legitimately contain a nested `terminator` line (e.g. the +// `doneanim` skip in parseAnimBlock โ€” anim blocks don't nest). For +// node-block skips use skipNodeBlock instead, which balances +// node/endnode pairs to defeat parser-desync attacks where an +// adversarial MDL injects an early fake `endnode` inside a deeper +// child to bleed remaining lines into the parent context. +// +// Behavior on malformed input: if `terminator` is missing entirely +// from the rest of the file, this consumes the remainder (up to +// `lines.size()`) and returns with `pos == lines.size()`. The outer +// parser loop then exits cleanly (its `while (pos < lines.size())` +// guard handles it). The result on a truncated/malformed MDL is "we +// load whatever was parseable before the corrupt block and silently +// drop everything after it" โ€” preferable to a parser desync that +// would either crash or load garbage as real data. +// +// Always terminates: `pos` is incremented unconditionally each +// iteration so the loop is bounded by `lines.size()` even if +// `terminator` never appears. +void skipToTerminator(const QStringList &lines, int &pos, + const QString &terminator) +{ + while (pos < lines.size()) { + const QString line = lines[pos].trimmed(); + pos++; + if (line == terminator) + break; + } +} + +// Advance `pos` past the matching `endnode` for the `node` block +// whose opening line is currently at `lines[pos]`. Tracks nested +// `node`/`endnode` pairs so a fake `endnode` line appearing inside a +// deeper child block does not close the outer block prematurely. +// +// Why this exists: skipToTerminator(..., "endnode") is a flat first- +// match skipper. After a parser cap fires (depth or node count) the +// flat skipper used to bleed adversarial lines into the parent +// context โ€” an attacker could craft an MDL where the would-be- +// skipped block contains a nested `node ...` then a fake `endnode` +// early; the flat skipper stops at the fake `endnode`, the parent's +// parseNodeBlock loop then interprets the remaining nested-block +// lines as parent geometry (`verts`, `faces`, ...) and re-incurs +// the very memory pressure the cap was meant to prevent. The +// balanced skipper closes that desync by counting opens and closes. +// +// Caller invariant: when invoked, `lines[pos]` must start with +// "node ". The function consumes that opening line first (depth +// rises to 1) and then scans until depth returns to 0. +// +// Behavior on missing terminator: same as skipToTerminator โ€” +// consumes to lines.size() and the outer parser exits cleanly on +// its bounds check. +// +// Always terminates: `pos` is incremented unconditionally each +// iteration. +void skipNodeBlock(const QStringList &lines, int &pos) +{ + if (pos >= lines.size()) + return; + pos++; // consume the opening `node ...` line + int depth = 1; + while (pos < lines.size() && depth > 0) { + const QString line = lines[pos].trimmed(); + pos++; + if (line.startsWith("node ")) + ++depth; + else if (line == "endnode") + --depth; + } +} + +} // namespace + +bool MdlNode::hasMesh() const +{ + return !verts.isEmpty() && !faces.isEmpty() && + (nodeType == "trimesh" || nodeType == "skin" || + nodeType == "danglymesh" || nodeType == "animmesh" || + nodeType == "aabb"); +} + +bool MdlScene::loadFromString(const QString &ascii) +{ + m_nodes.clear(); + m_nodeIndexByName.clear(); + m_childrenByParent.clear(); + m_animations.clear(); + m_loadWarnings.clear(); + m_nodesDroppedByCap = 0; + m_nodesDroppedByDepth = 0; + m_totalVerts = 0; + m_totalFaces = 0; + m_vertsDroppedByCap = 0; + m_facesDroppedByCap = 0; + + // Refuse oversized input before split('\n'), which would amplify + // an attacker's bytes by 3-5x (UTF-16 expansion + per-line + // QString allocations). Caller still gets the empty scene and + // can show the loadWarning to the user. + if (ascii.size() > kMaxInputChars) { + const QString msg = QStringLiteral( + "MDL too large: %1 MB source, max %2 MB. File rejected " + "before parse to avoid OOM. (Real ASCII MDLs are 1-20 MB; " + "anything larger is almost certainly a binary file with " + "the wrong extension or a malformed/crafted input.)") + .arg(ascii.size() / (1024 * 1024)) + .arg(kMaxInputChars / (1024 * 1024)); + m_loadWarnings.append(msg); + qWarning().noquote() << msg; + return false; + } + + QStringList lines = ascii.split('\n'); + int pos = 0; + bool inGeom = true; + while (pos < lines.size()) + { + QString line = lines[pos].trimmed(); + if (inGeom) + { + if (line.startsWith("node ")) + { + parseNodeBlock(lines, pos); + } + else if (line.startsWith("endmodelgeom")) + { + inGeom = false; + pos++; + } + else + { + pos++; + } + } + else + { + if (line.startsWith("newanim ")) + parseAnimBlock(lines, pos); + else if (line.startsWith("donemodel")) + break; + else + pos++; + } + } + + // Build the name -> first-occurrence-index map. Walking backward so + // QHash::insert leaves the lowest matching index for duplicate names, + // matching the legacy linear-scan-from-zero behavior of findNodeByName. + m_nodeIndexByName.reserve(m_nodes.size()); + for (int i = m_nodes.size() - 1; i >= 0; --i) + m_nodeIndexByName.insert(m_nodes[i].name, i); + + // Build the parent->children index. For each child, group it under + // every node sharing its parent name (matches the old + // O(N)-per-call linear-scan childrenOf semantics โ€” important + // because malformed MDLs with duplicate names would otherwise + // produce a different scene graph after this refactor). For + // typical scenes the inner lookup is O(1) via the temporary + // multi-map and the build cost is negligible (~50 hash ops + ~50 + // appends for a creature MDL). + // + // Worst-case load cost: a duplicate-name attack (every node + // shares the same name and parent name) does O(Nยฒ) appends here + // โ€” N=4096 (kMaxNodes) gives 16M ints / ~64 MB. Slow but bounded; + // unlike the old per-call linear scan, this cost is now incurred + // unconditionally at load before any render, so the kMaxNodes cap + // applied during parse is the load-bearing defense against this + // amplification. + { + QHash> nodesByName; + nodesByName.reserve(m_nodes.size()); + for (int i = 0; i < m_nodes.size(); ++i) + nodesByName[m_nodes[i].name].append(i); + + m_childrenByParent.resize(m_nodes.size()); + for (int childIdx = 0; childIdx < m_nodes.size(); ++childIdx) { + const QString &parentName = m_nodes[childIdx].parent; + if (parentName.isEmpty() || parentName.toLower() == "null") + continue; + auto it = nodesByName.constFind(parentName); + if (it == nodesByName.constEnd()) + continue; + for (int parentIdx : it.value()) { + if (parentIdx != childIdx) + m_childrenByParent[parentIdx].append(childIdx); + } + } + } + + // Surface coalesced parser-cap warnings to anyone who polls + // loadWarnings() (the viewport relays them as previewWarning + // signals so the UI can tell a partial scene from a successful + // one). qWarning() too so headless tests / CLI uses see them + // even when no UI is attached. Coalescing per category keeps the + // log readable when an attacker MDL claims thousands of nodes. + if (m_nodesDroppedByCap > 0) { + const QString msg = QStringLiteral( + "MDL exceeds %1-node cap: %2 node block(s) dropped during " + "load. Scene rendered with first %3 nodes; rest of the " + "model is missing. (Increase kMaxNodes if real content " + "is hitting this โ€” currently sized for largest BioWare " + "tiles plus headroom.)") + .arg(kMaxNodes).arg(m_nodesDroppedByCap).arg(m_nodes.size()); + m_loadWarnings.append(msg); + qWarning().noquote() << msg; + } + if (m_nodesDroppedByDepth > 0) { + const QString msg = QStringLiteral( + "MDL exceeds %1-level node-nesting cap: %2 node block(s) " + "dropped during load. The most deeply nested geometry " + "in this model is missing.") + .arg(kMaxNodeDepth).arg(m_nodesDroppedByDepth); + m_loadWarnings.append(msg); + qWarning().noquote() << msg; + } + if (m_vertsDroppedByCap > 0) { + const QString msg = QStringLiteral( + "MDL exceeds %1-vert scene cap: %2 verts array(s) dropped " + "during load (current scene total %3). Affected meshes " + "render as bind-pose skeletons or empty geometry.") + .arg(kMaxTotalVerts).arg(m_vertsDroppedByCap).arg(m_totalVerts); + m_loadWarnings.append(msg); + qWarning().noquote() << msg; + } + if (m_facesDroppedByCap > 0) { + const QString msg = QStringLiteral( + "MDL exceeds %1-face scene cap: %2 faces array(s) dropped " + "during load (current scene total %3). Affected meshes " + "have no triangles and will not render.") + .arg(kMaxTotalFaces).arg(m_facesDroppedByCap).arg(m_totalFaces); + m_loadWarnings.append(msg); + qWarning().noquote() << msg; + } + + return !m_nodes.isEmpty(); +} + +static QVector3D parseVec3(const QStringList &tokens, int offset = 0) +{ + if (tokens.size() < offset + 3) + return {}; + return {tokens[offset].toFloat(), tokens[offset + 1].toFloat(), tokens[offset + 2].toFloat()}; +} + +void MdlScene::parseNodeBlock(const QStringList &lines, int &pos, int depth) +{ + if (depth >= kMaxNodeDepth) { + // Skip the rest of this (and only this) `node` block instead + // of recursing further. Without this an attacker-controlled + // MDL can chain thousands of `node trimesh n0` lines (no + // `endnode`) and overflow the parser's recursion stack at + // load time. Use the balanced skipper so a deeper child + // block's fake `endnode` can't desync the parent parse. + ++m_nodesDroppedByDepth; + skipNodeBlock(lines, pos); + return; + } + if (m_nodes.size() >= kMaxNodes) { + // Cap on total nodes per scene. Without this, an attacker- + // controlled MDL claiming millions of `node` blocks at depth + // 0 can OOM the process during parse and amplify the + // m_childrenByParent build (O(Nยฒ) in the duplicate-name case) + // into a multi-second load hang. Skip past the rest of this + // block (balanced skip so nested fake-`endnode` injection + // can't desync); subsequent `node` blocks (siblings or + // top-level) will hit the same cap and skip too. + // + // Note: this is checked before adding a new node, but a + // parent whose recursive children all hit the cap still gets + // appended once at the bottom of the function. So the actual + // ceiling is kMaxNodes ancestors-of-the-cap-line plus one + // for the parent that owns the over-budget subtree โ€” + // effectively kMaxNodes + small constant. Treating kMaxNodes + // as a soft target rather than a hard ceiling is fine + // because the worst-case post-parse cost (m_childrenByParent + // ~64 MB at 4096ยฒ) doesn't materially change at 4097. + ++m_nodesDroppedByCap; + skipNodeBlock(lines, pos); + return; + } + + MdlNode node; + QStringList header = lines[pos].trimmed().split(QRegularExpression("\\s+")); + if (header.size() >= 3) + { + node.nodeType = header[1].toLower(); + node.name = header[2]; + } + pos++; + + // `Discard` is used when a verts/faces declaration would push the + // scene past kMaxTotalVerts / kMaxTotalFaces. We can't simply + // ignore the declaration: the next `count` non-blank lines are + // raw float/int tuples authored as data, not keywords. If we leave + // mode = None they'd flow into the keyword dispatch below and + // either silently mis-parse or trigger weird side effects (e.g. a + // float-prefixed line tokenised as a `position` directive on + // `node`). Discard consumes exactly `remaining` lines without + // touching the node, keeping the parser cursor aligned with where + // the file says the array ends. + enum class ArrayMode { None, Verts, Faces, TVerts, Normals, Weights, Discard }; + ArrayMode mode = ArrayMode::None; + int remaining = 0; + + while (pos < lines.size()) + { + QString line = lines[pos].trimmed(); + pos++; + + if (line.isEmpty() || line.startsWith('#')) + continue; + + if (line == "endnode") + break; + + if (line.startsWith("node ")) + { + pos--; + parseNodeBlock(lines, pos, depth + 1); + continue; + } + + if (mode != ArrayMode::None && remaining > 0) + { + QStringList tokens = line.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); + switch (mode) + { + case ArrayMode::Verts: + node.verts.append(parseVec3(tokens)); + break; + case ArrayMode::Normals: + node.normals.append(parseVec3(tokens)); + break; + case ArrayMode::TVerts: + node.tverts.append(parseVec3(tokens)); + break; + case ArrayMode::Faces: { + if (tokens.size() >= 8) + { + MdlFace f; + f.verts[0] = tokens[0].toInt(); + f.verts[1] = tokens[1].toInt(); + f.verts[2] = tokens[2].toInt(); + f.smoothGroup = tokens[3].toInt(); + f.uvs[0] = tokens[4].toInt(); + f.uvs[1] = tokens[5].toInt(); + f.uvs[2] = tokens[6].toInt(); + f.material = tokens[7].toInt(); + node.faces.append(f); + } + break; + } + case ArrayMode::Weights: { + // Format: bone_name weight [bone_name weight ...] + // Up to 4 bone influences per vertex in practice. + QVector wlist; + for (int i = 0; i + 1 < tokens.size(); i += 2) { + bool ok = false; + float w = tokens[i + 1].toFloat(&ok); + if (ok) + wlist.append({tokens[i], w}); + } + node.weights.append(wlist); + break; + } + case ArrayMode::Discard: + // Aggregate cap hit upstream โ€” drop the line on the + // floor, just keep the cursor advancing. + break; + default: + break; + } + remaining--; + if (remaining <= 0) + mode = ArrayMode::None; + continue; + } + + QStringList tokens = line.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); + if (tokens.isEmpty()) + continue; + + QString key = tokens[0].toLower(); + + if (key == "parent" && tokens.size() >= 2) + { + node.parent = tokens[1]; + } + else if (key == "position" && tokens.size() >= 4) + { + node.position = parseVec3(tokens, 1); + } + else if (key == "orientation" && tokens.size() >= 5) + { + node.orientation = parseAxisAngleQuat(tokens, 1); + } + else if (key == "scale" && tokens.size() >= 2) + { + node.scale = tokens[1].toFloat(); + } + else if (key == "ambient" && tokens.size() >= 4) + { + node.ambient = parseVec3(tokens, 1); + } + else if (key == "diffuse" && tokens.size() >= 4) + { + node.diffuse = parseVec3(tokens, 1); + } + else if (key == "specular" && tokens.size() >= 4) + { + node.specular = parseVec3(tokens, 1); + } + else if (key == "shininess" && tokens.size() >= 2) + { + node.shininess = tokens[1].toFloat(); + } + else if (key == "bitmap" && tokens.size() >= 2) + { + node.bitmap = tokens[1]; + } + else if (key == "render" && tokens.size() >= 2) + { + node.render = tokens[1].toInt(); + } + else if (key == "verts" && tokens.size() >= 2) + { + int count = tokens[1].toInt(); + if (count > 0 && count <= kMaxArraySize) + { + if (m_totalVerts + count > kMaxTotalVerts) { + // Per-array cap accepted this on its own, but + // adding it would push the scene past the + // aggregate budget. Discard the array (keeping the + // cursor aligned via Discard mode) and bump a + // counter that becomes a coalesced loadWarning. + ++m_vertsDroppedByCap; + mode = ArrayMode::Discard; + remaining = count; + } else { + mode = ArrayMode::Verts; + remaining = count; + node.verts.reserve(count); + m_totalVerts += count; + } + } + } + else if (key == "faces" && tokens.size() >= 2) + { + int count = tokens[1].toInt(); + if (count > 0 && count <= kMaxArraySize) + { + if (m_totalFaces + count > kMaxTotalFaces) { + ++m_facesDroppedByCap; + mode = ArrayMode::Discard; + remaining = count; + } else { + mode = ArrayMode::Faces; + remaining = count; + node.faces.reserve(count); + m_totalFaces += count; + } + } + } + else if (key == "tverts" && tokens.size() >= 2) + { + int count = tokens[1].toInt(); + if (count > 0 && count <= kMaxArraySize) + { + mode = ArrayMode::TVerts; + remaining = count; + node.tverts.reserve(count); + } + } + else if (key == "normals" && tokens.size() >= 2) + { + int count = tokens[1].toInt(); + if (count > 0 && count <= kMaxArraySize) + { + mode = ArrayMode::Normals; + remaining = count; + node.normals.reserve(count); + } + } + else if (key == "weights" && tokens.size() >= 2) + { + int count = tokens[1].toInt(); + if (count > 0 && count <= kMaxArraySize) + { + mode = ArrayMode::Weights; + remaining = count; + node.weights.reserve(count); + } + } + } + + m_nodes.append(node); +} + +int MdlScene::findNodeByName(const QString &name) const +{ + auto it = m_nodeIndexByName.constFind(name); + return it != m_nodeIndexByName.constEnd() ? it.value() : -1; +} + +QMatrix4x4 MdlScene::bindWorldTransformOf(int idx) const +{ + if (idx < 0 || idx >= m_nodes.size()) + return {}; + + // Iterative parent walk with a visited-set cycle guard. A malformed MDL + // with cyclic node.parent links (or two nodes that share a name and + // mutually parent into each other via findNodeByName) used to recurse + // until the stack overflowed; we now break the cycle and return what + // we've accumulated so far instead of crashing. Same chain-then- + // compose shape as MdlAnimationPlayer::recomputeBoneMatrices, which + // is the every-node / animated variant of this single-target walk. + QVector chain; + chain.reserve(16); + QSet visited; + int cur = idx; + while (cur >= 0 && cur < m_nodes.size() && !visited.contains(cur)) + { + visited.insert(cur); + const MdlNode &n = m_nodes[cur]; + chain.append(makeLocalTransform(n)); + int parentIdx = findNodeByName(n.parent); + if (parentIdx == cur) + break; + cur = parentIdx; + } + + // Compose top-down: world = root_local * ... * parent_local * self_local + // chain[size-1] is the root, chain[0] is `idx` itself. + QMatrix4x4 world; + for (int i = chain.size() - 1; i >= 0; --i) + world = world * chain[i]; + return world; +} + +void MdlScene::parseAnimBlock(const QStringList &lines, int &pos) +{ + // Short-circuit when the per-scene animation cap is already hit: skip + // straight past `doneanim` without building any keyframe arrays. A + // crafted MDL with millions of `newanim` blocks otherwise burns O(N) + // parser time per additional animation just to discard it later. + if (m_animations.size() >= kMaxAnimations) { + skipToTerminator(lines, pos, "doneanim"); + return; + } + + QStringList header = lines[pos].trimmed().split(QRegularExpression("\\s+")); + pos++; + if (header.size() < 2) + return; + + MdlAnimation anim; + anim.name = header[1]; + + while (pos < lines.size()) + { + QString line = lines[pos].trimmed(); + if (line.startsWith("doneanim")) + { + pos++; + break; + } + if (line.startsWith("node ")) + { + parseAnimNodeBlock(lines, pos, anim); + continue; + } + + QStringList tokens = line.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); + if (tokens.size() >= 2) { + const QString &k = tokens[0]; + if (k == "length") anim.length = tokens[1].toFloat(); + else if (k == "transtime") anim.transTime = tokens[1].toFloat(); + else if (k == "animroot") anim.animRoot = tokens[1]; + } + pos++; + } + + m_animations.append(anim); +} + +void MdlScene::parseAnimNodeBlock(const QStringList &lines, int &pos, MdlAnimation &anim) +{ + // First line is "node TYPE NAME". Capture name only. + QStringList header = lines[pos].trimmed().split(QRegularExpression("\\s+")); + pos++; + if (header.size() < 3) + return; + + // Short-circuit when the per-animation channel cap is already hit: + // walk to `endnode` without parsing keyframe lines. Avoids the same + // wasted-parse-then-discard pattern as parseAnimBlock above. + if (anim.channels.size() >= kMaxChannelsPerAnim) { + skipToTerminator(lines, pos, "endnode"); + return; + } + + QString nodeName = header[2]; + MdlAnimNodeChannels ch; + + enum class KeyMode { None, Position, Orientation, Scale }; + KeyMode mode = KeyMode::None; + + while (pos < lines.size()) + { + QString line = lines[pos].trimmed(); + pos++; + + if (line.isEmpty() || line.startsWith('#')) + continue; + if (line == "endnode") + break; + + QStringList tokens = line.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); + if (tokens.isEmpty()) + continue; + + // Controller list openers โ€” may have an optional count after them + // ("positionkey 5"), or just stand alone. Switch mode and continue. + const QString &k0 = tokens[0]; + if (k0 == "positionkey") { mode = KeyMode::Position; continue; } + if (k0 == "orientationkey") { mode = KeyMode::Orientation; continue; } + if (k0 == "scalekey") { mode = KeyMode::Scale; continue; } + if (k0 == "endlist") { mode = KeyMode::None; continue; } + + // Static (single-keyframe) controllers: the value applies for the + // whole animation. Encoded as a single keyframe at t=0. + if (mode == KeyMode::None) { + if (k0 == "position") appendPosKey(ch, 0.0f, tokens, 1); + else if (k0 == "orientation") appendOriKey(ch, 0.0f, tokens, 1); + else if (k0 == "scale") appendSclKey(ch, 0.0f, tokens, 1); + continue; + } + + // Inside a *key block: each line is "time ". + bool ok = false; + float t = tokens[0].toFloat(&ok); + if (!ok) continue; + + switch (mode) { + case KeyMode::Position: appendPosKey(ch, t, tokens, 1); break; + case KeyMode::Orientation: appendOriKey(ch, t, tokens, 1); break; + case KeyMode::Scale: appendSclKey(ch, t, tokens, 1); break; + case KeyMode::None: break; + } + } + + if (!ch.isEmpty()) + anim.channels.insert(nodeName, ch); +} + +QStringList MdlScene::animationNames() const +{ + QStringList out; + out.reserve(m_animations.size()); + for (const auto &a : m_animations) + out.append(a.name); + return out; +} + +const MdlAnimation *MdlScene::findAnimation(const QString &name) const +{ + for (const auto &a : m_animations) + if (a.name.compare(name, Qt::CaseInsensitive) == 0) + return &a; + return nullptr; +} + +QVector MdlScene::skinnedVertsWith(int idx, + const QHash &boneWorld) const +{ + if (idx < 0 || idx >= m_nodes.size()) + return {}; + const MdlNode &n = m_nodes[idx]; + if (!n.isSkin()) + return n.verts; + + // Verts in a skin node are authored in the skin node's local space; lift + // them into model space at bind first, since linear-blend skinning is + // defined on the rest-pose world position of each vertex. + const QMatrix4x4 skinBindWorld = bindWorldTransformOf(idx); + + // Resolve each unique bone once. Animated world transform comes from + // boneWorld when supplied (driven by an MdlAnimationPlayer); otherwise + // we fall back to the bind world transform, in which case deform = + // identity and the result is the bind-pose model-space verts. + // Cache keyed by bone name (the weight list addresses bones by name) + // but the boneWorld lookup itself uses the node index โ€” names can + // alias in malformed MDLs, indices cannot. + struct BoneXform { + QMatrix4x4 deform; // currentWorld * bindWorld^-1 + bool valid; + }; + QHash bones; + auto boneOf = [&](const QString &name) -> BoneXform { + auto it = bones.find(name); + if (it != bones.end()) + return it.value(); + BoneXform bx{}; + int bi = findNodeByName(name); + if (bi >= 0) { + bool invertible = false; + QMatrix4x4 bindWorld = bindWorldTransformOf(bi); + QMatrix4x4 bindInv = bindWorld.inverted(&invertible); + if (invertible) { + // boneWorld constFind / fallback-to-bindWorld pattern. + // Same shape appears in Renderer::buildRenderNodes + // (fallback: parentWorld * makeLocalTransform) and + // Renderer::updateAnimatedMeshes (fallback: skip). The + // three fallback bodies differ enough that extraction + // would obscure intent. + auto bw = boneWorld.constFind(bi); + const QMatrix4x4 &cur = (bw != boneWorld.constEnd()) + ? bw.value() : bindWorld; + bx.deform = cur * bindInv; + bx.valid = true; + } + } + bones.insert(name, bx); + return bx; + }; + + QVector out; + out.reserve(n.verts.size()); + for (int i = 0; i < n.verts.size(); ++i) { + const QVector3D vModel = skinBindWorld.map(n.verts[i]); + if (i >= n.weights.size() || n.weights[i].isEmpty()) { + out.append(vModel); + continue; + } + QVector3D acc(0, 0, 0); + float total = 0.0f; + for (const auto &w : n.weights[i]) { + BoneXform bx = boneOf(w.boneName); + if (!bx.valid) + continue; + acc += w.weight * bx.deform.map(vModel); + total += w.weight; + } + if (total <= 0.0f) { + out.append(vModel); + } else { + if (std::abs(total - 1.0f) > 0.001f) + acc /= total; + out.append(acc); + } + } + return out; +} + +int MdlScene::rootIndex() const +{ + for (int i = 0; i < m_nodes.size(); ++i) + { + if (m_nodes[i].parent.toLower() == "null" || m_nodes[i].parent.isEmpty()) + return i; + } + return m_nodes.isEmpty() ? -1 : 0; +} + +const QVector &MdlScene::childrenOf(int idx) const +{ + static const QVector empty; + if (idx < 0 || idx >= m_childrenByParent.size()) + return empty; + return m_childrenByParent[idx]; +} + +void MdlScene::computeBounds(QVector3D &bmin, QVector3D &bmax) const +{ + bmin = QVector3D(FLT_MAX, FLT_MAX, FLT_MAX); + bmax = QVector3D(-FLT_MAX, -FLT_MAX, -FLT_MAX); + + int root = rootIndex(); + if (root < 0) { + bmin = QVector3D(-1, -1, -1); + bmax = QVector3D(1, 1, 1); + return; + } + + QMatrix4x4 identity; + bool any = false; + QSet visited; + accumulateBoundsFromRoot(root, identity, bmin, bmax, any, visited); + + if (!any) { + bmin = QVector3D(-1, -1, -1); + bmax = QVector3D(1, 1, 1); + } +} + +void MdlScene::accumulateBoundsFromRoot(int rootIdx, const QMatrix4x4 &rootParentWorld, + QVector3D &bmin, QVector3D &bmax, bool &any, + QSet &visited) const +{ + // Iterative DFS on a heap-allocated worklist. Shares its graph- + // walk shape with Renderer::buildRenderNodes (same childrenOf + // walk, same cycle guard, same reverse-child push for declaration + // order) but the per-node math is different: bounds always use + // the bind-pose hierarchy (`parentWorld * makeLocalTransform`), + // whereas the renderer prefers `boneWorld[idx]` when the + // animation player has it. Bounds are therefore the bind-pose + // AABB and will not track an animated frame's reach โ€” callers + // wanting an animation-aware bounding box need to walk the + // renderer's RenderNode list, not this function. The explicit + // stack keeps deep linear chains bounded by heap size instead of + // thread stack size; the visited set short-circuits cycles + // introduced by malformed MDLs. See the matching comment on + // Renderer::buildRenderNodes for the heap vs stack trade-off + // (peak worklist is O(depth + max_sibling_fanout) per node). + QStack> stack; + stack.push({rootIdx, rootParentWorld}); + + while (!stack.isEmpty()) { + const auto [idx, parentWorld] = stack.pop(); + if (visited.contains(idx)) + continue; + visited.insert(idx); + + const MdlNode &node = m_nodes[idx]; + QMatrix4x4 world = parentWorld * makeLocalTransform(node); + + for (const auto &v : node.verts) { + QVector3D wp = world.map(v); + bmin.setX(std::min(bmin.x(), wp.x())); + bmin.setY(std::min(bmin.y(), wp.y())); + bmin.setZ(std::min(bmin.z(), wp.z())); + bmax.setX(std::max(bmax.x(), wp.x())); + bmax.setY(std::max(bmax.y(), wp.y())); + bmax.setZ(std::max(bmax.z(), wp.z())); + any = true; + } + + // Reverse-push children so DFS visits in declaration order + // (no visible effect on bounds; mirrors buildRenderNodes for + // consistency). + const QVector &children = childrenOf(idx); + for (int i = children.size() - 1; i >= 0; --i) + stack.push({children[i], world}); + } +} diff --git a/mdlscene.h b/mdlscene.h new file mode 100644 index 0000000..5b375ce --- /dev/null +++ b/mdlscene.h @@ -0,0 +1,229 @@ +#ifndef MDLSCENE_H +#define MDLSCENE_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct MdlFace { + int32_t verts[3]; + int32_t uvs[3]; + int32_t smoothGroup; + int32_t material; +}; + +struct MdlVertWeight { + QString boneName; + float weight; +}; + +struct MdlNode; + +// Build a local transform matrix from a (translate, rotate, scale) triple +// using NWN's convention: translate first, then rotate, then uniform scale. +// Lives in the header so the parser, animation player, and renderer all +// build identical local transforms without copy-pasting the four lines. +inline QMatrix4x4 makeLocalTransform(const QVector3D &pos, + const QQuaternion &ori, + float scl) +{ + QMatrix4x4 m; + m.translate(pos); + m.rotate(ori); + if (scl != 1.0f) + m.scale(scl); + return m; +} + +// Convenience overload for the common case "build the bind-pose local +// transform of this node". Defined out-of-line below so it can see +// MdlNode's full definition without a circular declaration. +inline QMatrix4x4 makeLocalTransform(const MdlNode &node); + +// Full keyframe lists for one node within one animation. Times are seconds +// from animation start, sorted ascending. Empty channels mean "node not +// animated by this controller โ€” fall back to bind pose". +struct MdlAnimNodeChannels { + QVector posTimes; + QVector posValues; + QVector oriTimes; + QVector oriValues; + QVector sclTimes; + QVector sclValues; + + bool isEmpty() const { + return posTimes.isEmpty() && oriTimes.isEmpty() && sclTimes.isEmpty(); + } +}; + +struct MdlAnimation { + QString name; + float length = 0.0f; // seconds; from "length T" in the anim header + float transTime = 0.0f; // seconds; transition fade hint, unused for now + QString animRoot; // subtree to apply to ("animroot NAME") + QHash channels; // key: node name +}; + +struct MdlNode { + QString name; + QString parent; + QString nodeType; + + // Bind-pose local transform from the parsed scene. The scene is never + // mutated after parsing โ€” animated transforms live in MdlAnimationPlayer + // and are composed at render time, so position/orientation/scale here + // always describe the rest pose. + QVector3D position; + QQuaternion orientation; + float scale = 1.0f; + + QVector3D ambient{0.2f, 0.2f, 0.2f}; + QVector3D diffuse{0.8f, 0.8f, 0.8f}; + QVector3D specular{0.0f, 0.0f, 0.0f}; + float shininess = 1.0f; + QString bitmap; + int render = 1; + + QVector verts; + QVector normals; + QVector tverts; + QVector faces; + + // Skin-node only: per-vertex bone-weight pairs. + // weights[i] is the weight list for verts[i]. + QVector> weights; + + bool hasMesh() const; + bool isSkin() const { return nodeType == "skin" && !weights.isEmpty(); } +}; + +inline QMatrix4x4 makeLocalTransform(const MdlNode &node) +{ + return makeLocalTransform(node.position, node.orientation, node.scale); +} + +// Holds a parsed MDL scene plus a few precomputed indices +// (m_nodeIndexByName, m_childrenByParent) that downstream code reads +// hot. Lifetime invariant: m_nodes is populated only by +// loadFromString and never mutated thereafter; the caches are valid +// from the moment loadFromString returns true until the next +// loadFromString call (which clears and rebuilds them atomically). +// Do not add APIs that mutate m_nodes (or any field referenced by a +// cache, e.g. MdlNode::name / MdlNode::parent) without rebuilding +// every cache or the graph walks will silently see a stale view. +class MdlScene +{ +public: + bool loadFromString(const QString &ascii); + + // Non-fatal warnings produced during the most recent successful + // loadFromString. Examples: "MDL exceeds 4096-node cap; 3217 + // nodes dropped." UI layers should surface these so end users can + // tell that a model loaded as a partial scene rather than + // "looking wrong" with no explanation. Cleared at the start of + // every loadFromString. + const QStringList &loadWarnings() const { return m_loadWarnings; } + + const QVector &nodes() const { return m_nodes; } + int rootIndex() const; + // Returns a const reference into the precomputed parent->children + // table built once in loadFromString. Callers that walk the scene + // graph (renderer, bounds, animation player) used to call this + // O(N) per visited node, paying O(Nยฒ) total for what is morally + // a single tree traversal. The cache makes each call O(1) plus + // the cost of any QVector copy the caller forces; prefer + // `const auto &c = childrenOf(idx)` to avoid the copy. + const QVector &childrenOf(int idx) const; + + // O(1) name -> index lookup backed by m_nodeIndexByName. Returns the + // first matching index for duplicate names, mirroring legacy ordering. + int findNodeByName(const QString &name) const; + + // World-space transform of node `idx` at the parsed (bind) pose. + // Iterative + visited-set guarded: a malformed MDL with a parent cycle + // returns identity for the cycle members instead of overflowing the + // stack. Required by skinning math (we invert it to lift verts into + // bone-local space). + QMatrix4x4 bindWorldTransformOf(int idx) const; + + // For a skin node, returns its verts in model-space coordinates after + // linear-blend skinning by each vertex's weighted bones: + // v' = ฮฃ w_i * (bone_i.currentWorld * bone_i.bindWorld^-1) * v_modelBind + // `boneWorld` supplies the *current* world transforms (from + // MdlAnimationPlayer), keyed by node index. Bones missing from the + // map fall back to their bind world, which collapses to identity + // deform. An empty `boneWorld` therefore returns bind-pose model- + // space verts. Non-skin nodes (or invalid idx) get their raw verts + // back unchanged. + QVector skinnedVertsWith(int idx, + const QHash &boneWorld) const; + + void computeBounds(QVector3D &bmin, QVector3D &bmax) const; + + // Names of all parsed animations, in file order. + QStringList animationNames() const; + + // Read-only access to parsed animations for the player. + const QVector &animations() const { return m_animations; } + const MdlAnimation *findAnimation(const QString &name) const; + +private: + QVector m_nodes; + // Name -> first-occurrence index. Built once in loadFromString so + // findNodeByName is O(1) even when the animation player walks parent + // chains for every node every frame. + QHash m_nodeIndexByName; + // parent-index -> list of child indices. Built once in + // loadFromString from the parsed (parent-name, child-index) pairs; + // mirrors the original linear-scan childrenOf semantics including + // the "child appears under every parent that shares the name" + // behavior for malformed MDLs with duplicate node names. + QVector> m_childrenByParent; + QVector m_animations; + // Warnings accumulated during the most recent loadFromString. + // Counters during parse (e.g. m_nodesDroppedByCap) feed a + // single coalesced warning string at the end of load โ€” the + // alternative of one warning per cap hit would flood the UI + // when an attacker-crafted MDL claims thousands of nodes. + QStringList m_loadWarnings; + int m_nodesDroppedByCap = 0; + int m_nodesDroppedByDepth = 0; + // Scene-wide running totals across all node geometry. The per-node + // / per-array caps in mdlscene.cpp's anonymous namespace bound any + // *single* declaration, but their product (kMaxNodes * kMaxArraySize + // โ‰ˆ 4ยท10^10 verts) is unbounded in aggregate. These counters let + // parseNodeBlock skip any verts/faces declaration that would push + // the scene past kMaxTotalVerts / kMaxTotalFaces, so a fan of 50 + // nodes each declaring 1M verts can't sneak past the per-node cap + // and OOM the process. Counters drop hits feed a coalesced warning + // surfaced via loadWarnings(). + qint64 m_totalVerts = 0; + qint64 m_totalFaces = 0; + int m_vertsDroppedByCap = 0; + int m_facesDroppedByCap = 0; + + // `depth` bounds nested-`node` recursion. NWN's deepest legitimate + // hierarchy is well under 100 levels; we cap conservatively to keep + // a malicious MDL with thousands of `node ... node ... node` + // (no intervening `endnode`) from blowing the stack. + void parseNodeBlock(const QStringList &lines, int &pos, int depth = 0); + void parseAnimBlock(const QStringList &lines, int &pos); + void parseAnimNodeBlock(const QStringList &lines, int &pos, MdlAnimation &anim); + // Iterative DFS over the parent->child graph from `rootIdx`. The + // `visited` set short-circuits cyclic chains (two nodes claiming + // each other as parent, or longer cycles); the explicit + // worklist also keeps deep linear chains (thousands of nodes + // parented head-to-tail) bounded by heap size instead of thread + // stack size. + void accumulateBoundsFromRoot(int rootIdx, const QMatrix4x4 &rootParentWorld, + QVector3D &bmin, QVector3D &bmax, bool &any, + QSet &visited) const; +}; + +#endif diff --git a/metrics.h b/metrics.h new file mode 100644 index 0000000..9b8d373 --- /dev/null +++ b/metrics.h @@ -0,0 +1,34 @@ +#ifndef METRICS_H +#define METRICS_H + +#include "constants.h" + +#include +#include + +// Convert the em-multiplier design tokens declared in `Layout` into pixel +// values bound to a specific widget's font metrics. Calling sites pass their +// own `fontMetrics()` so the chrome scales with whatever font Qt has handed +// the widget โ€” including system-level accessibility bumps and HiDPI scaling +// that happens after `constants.h` was compiled. +// +// Vertical sizing keys off `fontMetrics().height()` (the widget's line +// height) so a row-of-text-and-icon stays proportional. Horizontal sizing +// keys off `horizontalAdvance("0")` so "9 chars wide" really is roughly 9 +// digits in whatever font the widget is using โ€” including monospace +// log fonts vs. proportional UI fonts. +namespace Layout { + +inline int emH(const QFontMetrics &fm, double em) +{ + return qRound(fm.height() * em); +} + +inline int emW(const QFontMetrics &fm, double chars) +{ + return qRound(fm.horizontalAdvance(QLatin1Char('0')) * chars); +} + +} // namespace Layout + +#endif // METRICS_H diff --git a/modelviewport.cpp b/modelviewport.cpp new file mode 100644 index 0000000..57dc23b --- /dev/null +++ b/modelviewport.cpp @@ -0,0 +1,442 @@ +#include "constants.h" +#include "modelviewport.h" +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// NWN creature idle animations, tried in order. The first one that exists +// becomes the auto-play idle. Identical fallback chain used for both the +// main viewport and the reference-overlay viewport โ€” single source of +// truth so adding a new idle name (e.g. cpause2) updates both paths. +const QStringList &preferredIdleAnimations() +{ + static const QStringList kList = { + "cpause1", "cstand", "pause1", "stand", "cpause", "cwalk", "default" + }; + return kList; +} + +// Animation tick cadence. The fallback dt is what the timer callback +// uses on the very first tick (when m_clock has not yet been started, +// or right after a hide/show transition). Both numbers must match: a +// 16ms interval driven by a fallback of e.g. 0.030 produces visible +// drift on the first frame of every restart. +constexpr int kAnimTickIntervalMs = 16; +constexpr float kAnimDefaultDtSeconds = kAnimTickIntervalMs / 1000.0f; + +} // namespace + +ModelViewport::ModelViewport(QWidget *parent) + : QOpenGLWidget(parent) +{ + setMouseTracking(true); + setFocusPolicy(Qt::StrongFocus); + + // ~60Hz animation tick. Only runs while a model with active animation + // is loaded AND the widget is currently visible (hideEvent stops it, + // showEvent restarts it). + m_animTimer.setInterval(kAnimTickIntervalMs); + m_animTimer.setTimerType(Qt::PreciseTimer); + QObject::connect(&m_animTimer, &QTimer::timeout, this, [this]() { + if (!m_initialized || !m_hasModel) + return; + const float dt = m_clock.isValid() + ? static_cast(m_clock.restart()) / 1000.0f + : kAnimDefaultDtSeconds; + if (m_player.update(dt)) + pushAnimatedFrame(); + }); +} + +void ModelViewport::pushAnimatedFrame() +{ + if (!m_initialized) + return; + GlContextGuard ctx(this); + m_renderer.updateAnimatedMeshes(this, m_scene, m_player.boneWorldMatrices()); + update(); +} + +void ModelViewport::startAnimationTickIfVisible() +{ + if (!m_initialized || !m_hasModel || !isPlayingAnimation() || !isVisible()) + return; + m_clock.start(); + m_animTimer.start(); +} + +void ModelViewport::stopAnimationTick() +{ + m_animTimer.stop(); + m_clock.invalidate(); +} + +void ModelViewport::hideEvent(QHideEvent *event) +{ + // Pause animation work while hidden. We don't tear down state โ€” just + // stop ticking. showEvent restarts the timer if we still have an + // animation playing. + stopAnimationTick(); + QOpenGLWidget::hideEvent(event); +} + +void ModelViewport::showEvent(QShowEvent *event) +{ + QOpenGLWidget::showEvent(event); + startAnimationTickIfVisible(); +} + +ModelViewport::~ModelViewport() +{ + if (!m_initialized) + return; + GlContextGuard ctx(this); + m_renderer.shutdown(this); +} + +void ModelViewport::setCliBinaryPath(const QString &path) +{ + m_cliBinaryPath = path; +} + +void ModelViewport::initializeGL() +{ + if (!initializeOpenGLFunctions()) { + qWarning() << "VIEWPORT: Failed to initialize OpenGL 3.3 Core functions"; + return; + } + + m_renderer.initialize(this); + m_initialized = true; + + glViewport(0, 0, width(), height()); + m_camera.setAspectRatio(static_cast(width()) / static_cast(std::max(height(), 1))); +} + +void ModelViewport::resizeGL(int w, int h) +{ + if (h == 0) h = 1; + m_camera.setAspectRatio(static_cast(w) / static_cast(h)); + if (m_initialized) + glViewport(0, 0, w, h); +} + +void ModelViewport::paintGL() +{ + if (!m_initialized) + return; + m_renderer.render(this, m_camera); +} + +bool ModelViewport::fileIsBinaryMdl(const QString &path) +{ + QFile f(path); + if (!f.open(QIODevice::ReadOnly)) + return false; + QByteArray hdr = f.read(4); + f.close(); + return hdr.size() == 4 && hdr[0] == 0 && hdr[1] == 0 && hdr[2] == 0 && hdr[3] == 0; +} + +void ModelViewport::decompileAsync(const QString &mdlPath, + std::function onSuccess, + std::function onError) +{ + if (!fileIsBinaryMdl(mdlPath)) { + QFile f(mdlPath); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { + onError("Cannot open " + mdlPath); + return; + } + onSuccess(QString::fromUtf8(f.readAll())); + return; + } + + if (m_cliBinaryPath.isEmpty()) { + onError("CLI binary path not set"); + return; + } + + auto *proc = new QProcess(this); + proc->setProgram(m_cliBinaryPath); + proc->setArguments({CliCommand::Decompile, mdlPath}); + + // Watchdog: bound the runtime so a stuck decompile cannot leak forever. + // `proc` parents the timer, so destroying the process tears down the timer. + auto *watchdog = new QTimer(proc); + watchdog->setSingleShot(true); + QObject::connect(watchdog, &QTimer::timeout, proc, [proc, mdlPath, onError]() { + if (proc->state() == QProcess::NotRunning) + return; + proc->kill(); + onError("CLI timed out decompiling " + mdlPath); + }); + + QObject::connect(proc, &QProcess::errorOccurred, this, + [proc, mdlPath, onError](QProcess::ProcessError err) { + if (err == QProcess::FailedToStart) { + onError("CLI failed to start for " + mdlPath + ": " + proc->errorString()); + proc->deleteLater(); + } + }); + + QObject::connect(proc, QOverload::of(&QProcess::finished), this, + [proc, watchdog, mdlPath, onSuccess, onError](int exitCode, QProcess::ExitStatus status) { + watchdog->stop(); + if (status == QProcess::CrashExit) { + onError("CLI crashed while decompiling " + mdlPath); + } else if (exitCode != 0) { + const QByteArray err = proc->readAllStandardError(); + onError("CLI error decompiling " + mdlPath + ": " + + (err.isEmpty() ? QStringLiteral("exit code %1").arg(exitCode) + : QString::fromUtf8(err))); + } else { + onSuccess(QString::fromUtf8(proc->readAllStandardOutput())); + } + proc->deleteLater(); + }); + + proc->start(QIODevice::ReadOnly); + watchdog->start(CliDefaults::ProcessTimeoutMs); +} + +void ModelViewport::previewFile(const QString &mdlPath) +{ + if (!m_initialized) { + emit previewError("Viewport not initialized"); + return; + } + + QPointer self(this); + decompileAsync(mdlPath, + [self, mdlPath](const QString &ascii) { + if (!self) return; + if (ascii.isEmpty()) { + emit self->previewError("Empty MDL content for " + mdlPath); + return; + } + self->loadModel(ascii, QFileInfo(mdlPath).absolutePath()); + }, + [self](const QString &err) { + if (!self) return; + emit self->previewError(err); + }); +} + +void ModelViewport::loadModel(const QString &asciiMdl, const QString &textureDir) +{ + if (!m_initialized) + return; + + stopAnimationTick(); + + MdlScene fresh; + const bool ok = fresh.loadFromString(asciiMdl); + // Relay loadFromString's accumulated warnings on both success and + // failure paths. Some hard-fail conditions (e.g. input-size cap) + // populate loadWarnings before returning false, and a bare + // "Failed to parse MDL scene" error message hides the actual + // reason from the user. + for (const QString &w : fresh.loadWarnings()) + emit previewWarning(w); + if (!ok) { + emit previewError("Failed to parse MDL scene"); + return; + } + m_scene = std::move(fresh); + + // Wire the player to the new scene first so its boneWorldMatrices() + // reflect either bind pose (no animation) or the chosen idle pose at + // frame 0. The renderer then uploads geometry once using the same + // bone world matrices the runtime updates use, so there's no bind- + // pose flash before the timer kicks in and no duplicate code path. + m_player.setScene(&m_scene); + QString playing = m_player.playPreferred(preferredIdleAnimations()); + + { + GlContextGuard ctx(this); + m_renderer.prepareScene(this, m_scene, m_player.boneWorldMatrices(), textureDir); + } + + QVector3D bmin, bmax; + m_scene.computeBounds(bmin, bmax); + m_camera.focusOnBounds(bmin, bmax); + + m_hasModel = true; + // Must come after m_hasModel = true; the helper bails out if no + // model is loaded. + startAnimationTickIfVisible(); + update(); + + emit animationsAvailable(m_scene.animationNames(), playing); +} + +QStringList ModelViewport::animationNames() const +{ + return m_scene.animationNames(); +} + +QString ModelViewport::currentAnimation() const +{ + return m_player.currentName(); +} + +void ModelViewport::playAnimation(const QString &name) +{ + if (!m_initialized || !m_hasModel) + return; + if (name.isEmpty()) { + m_player.stop(); + stopAnimationTick(); + // One last update so the mesh snaps back to bind pose. + pushAnimatedFrame(); + return; + } + if (!m_player.play(name)) + return; + pushAnimatedFrame(); + startAnimationTickIfVisible(); +} + +bool ModelViewport::isPlayingAnimation() const +{ + return m_player.state() == MdlAnimationPlayer::State::Playing; +} + +void ModelViewport::clearModel() +{ + if (!m_initialized) + return; + stopAnimationTick(); + // setScene(nullptr) calls stop() internally; no need to call both. + m_player.setScene(nullptr); + m_scene = MdlScene(); + { + GlContextGuard ctx(this); + m_renderer.prepareScene(this, m_scene); + } + m_hasModel = false; + update(); + emit animationsAvailable({}, {}); +} + + +void ModelViewport::setWireframe(bool on) +{ + m_renderer.setWireframe(on); + if (m_initialized) update(); +} + +bool ModelViewport::wireframe() const { return m_renderer.wireframe(); } + +void ModelViewport::setShowGrid(bool on) +{ + m_renderer.setShowGrid(on); + if (m_initialized) update(); +} + +bool ModelViewport::showGrid() const { return m_renderer.showGrid(); } + +void ModelViewport::loadReferenceFile(const QString &mdlPath) +{ + if (!m_initialized) return; + + QPointer self(this); + decompileAsync(mdlPath, + [self, mdlPath](const QString &ascii) { + if (!self || ascii.isEmpty()) return; + MdlScene scene; + const bool ok = scene.loadFromString(ascii); + // Same warning relay as loadModel: relay before checking + // the return value so input-size-cap (and other hard-fail) + // warnings reach the user even when the reference scene + // is empty. + for (const QString &w : scene.loadWarnings()) + emit self->previewWarning(w); + if (!ok) return; + + // Use a temporary animation player to evaluate frame 0 of the + // first available idle animation. The renderer uploads the + // reference geometry once using the resulting bone-world map; + // both the player and the scene can then be discarded โ€” the + // reference is static after upload (see prepareReferenceModel, + // which clears sceneNodeIdx so no later code dereferences the + // out-of-scope MdlScene). + MdlAnimationPlayer tempPlayer; + tempPlayer.setScene(&scene); + tempPlayer.playPreferred(preferredIdleAnimations()); + + { + GlContextGuard ctx(self); + self->m_renderer.prepareReferenceModel( + self, scene, tempPlayer.boneWorldMatrices(), + QFileInfo(mdlPath).absolutePath()); + } + self->update(); + }, + [](const QString &) { + // Reference loads are best-effort; failures stay silent here so + // they don't interrupt a decompile preview already on screen. + }); +} + +void ModelViewport::clearReference() +{ + if (!m_initialized) return; + { + GlContextGuard ctx(this); + m_renderer.clearReferenceModel(this); + } + update(); +} + +bool ModelViewport::hasReference() const { return m_renderer.showReference(); } + +void ModelViewport::mousePressEvent(QMouseEvent *event) +{ + m_lastMousePos = event->position(); + m_pressedButtons = event->buttons(); + event->accept(); +} + +void ModelViewport::mouseMoveEvent(QMouseEvent *event) +{ + QPointF delta = event->position() - m_lastMousePos; + m_lastMousePos = event->position(); + + if (m_pressedButtons & Qt::MiddleButton) { + if (event->modifiers() & Qt::ShiftModifier) + m_camera.pan(static_cast(delta.x()), static_cast(delta.y())); + else + m_camera.rotate(static_cast(delta.x()), static_cast(delta.y())); + update(); + } else if (m_pressedButtons & Qt::RightButton) { + m_camera.pan(static_cast(delta.x()), static_cast(delta.y())); + update(); + } else if (m_pressedButtons & Qt::LeftButton) { + m_camera.rotate(static_cast(delta.x()), static_cast(delta.y())); + update(); + } + + event->accept(); +} + +void ModelViewport::mouseReleaseEvent(QMouseEvent *event) +{ + m_pressedButtons = event->buttons(); + event->accept(); +} + +void ModelViewport::wheelEvent(QWheelEvent *event) +{ + float delta = static_cast(event->angleDelta().y()) / 120.0f; + m_camera.zoom(delta); + update(); + event->accept(); +} diff --git a/modelviewport.h b/modelviewport.h new file mode 100644 index 0000000..2c8e6bd --- /dev/null +++ b/modelviewport.h @@ -0,0 +1,137 @@ +#ifndef MODELVIEWPORT_H +#define MODELVIEWPORT_H + +#include "camera.h" +#include "glcontextguard.h" +#include "mdlanimationplayer.h" +#include "mdlscene.h" +#include "renderer.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class ModelViewport : public QOpenGLWidget, protected QOpenGLFunctions_3_3_Core +{ + Q_OBJECT + +public: + explicit ModelViewport(QWidget *parent = nullptr); + ~ModelViewport() override; + + void setCliBinaryPath(const QString &path); + void previewFile(const QString &mdlPath); + void loadModel(const QString &asciiMdl, const QString &textureDir = QString()); + void clearModel(); + + void setWireframe(bool on); + bool wireframe() const; + void setShowGrid(bool on); + bool showGrid() const; + + void loadReferenceFile(const QString &mdlPath); + void clearReference(); + bool hasReference() const; + + Camera &camera() { return m_camera; } + const Camera &camera() const { return m_camera; } + + // Animation control. Lists the animation names parsed from the loaded + // MDL (empty until a model is loaded). Pass an empty string to stop + // playback and snap back to bind pose. + QStringList animationNames() const; + QString currentAnimation() const; + void playAnimation(const QString &name); + bool isPlayingAnimation() const; + + static bool fileIsBinaryMdl(const QString &path); + +signals: + void previewError(const QString &msg); + // Non-fatal load warnings (parser hit a cap, dropped nodes, etc.). + // Distinct from previewError because the model DID load and is + // visible โ€” the warning just tells the user the scene is partial. + // The main window relays these to the debug log so end users can + // tell a "looks wrong" model apart from a "looks right" one. + void previewWarning(const QString &msg); + // Fires after a model loads with the freshly-parsed animation list. + // Receivers (e.g. the toolbar combobox) should rebuild their UI. + void animationsAvailable(const QStringList &names, const QString &nowPlaying); + +protected: + void initializeGL() override; + void resizeGL(int w, int h) override; + void paintGL() override; + + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void wheelEvent(QWheelEvent *event) override; + + // Pause the 60Hz animation tick when the widget is invisible (tab + // switched away, dock hidden, window minimised) and resume on show. + // Without this, hidden viewports keep CPU-skinning + uploading + + // scheduling repaints they'll never actually paint. + void hideEvent(QHideEvent *event) override; + void showEvent(QShowEvent *event) override; + +private: + // Reads `mdlPath` and asynchronously delivers the ASCII representation to + // `onSuccess`, or a human-readable failure message to `onError`. ASCII + // sources resolve synchronously inside this call; binary sources spawn a + // `cleanmodels decompile` subprocess and resolve when it finishes. A + // watchdog timer kills runaway processes after CliDefaults::ProcessTimeoutMs. + void decompileAsync(const QString &mdlPath, + std::function onSuccess, + std::function onError); + + // Wrap (GlContextGuard โ†’ updateAnimatedMeshes โ†’ update()) in a + // single helper. Multiple call sites (timer tick, loadModel post- + // play, playAnimation) need to push exactly the same animated- + // frame sequence: re-skin every mesh against the player's current + // bone-world matrices and request a repaint. GlContextGuard + // handles the makeCurrent/doneCurrent pairing; this function + // exists to keep the rest of the sequence in one place so the + // updateAnimatedMeshes/update() pair can't drift apart. + void pushAnimatedFrame(); + + // Restart the 60Hz animation tick if (and only if) the widget is + // visible AND a model is loaded AND the player is in Playing state. + // No-op otherwise. Single source of truth for the (m_clock.start(); + // m_animTimer.start()) pair โ€” without it the precondition check was + // open-coded at three call sites (loadModel, playAnimation, + // showEvent) with subtly different conditions. + void startAnimationTickIfVisible(); + + // Symmetric counterpart to startAnimationTickIfVisible: stop the + // 60Hz tick and invalidate the dt clock so the next start gets a + // fresh baseline. Open-coded at four sites before extraction + // (hideEvent, loadModel, playAnimation, clearModel). + void stopAnimationTick(); + + Renderer m_renderer; + Camera m_camera; + QString m_cliBinaryPath; + bool m_initialized = false; + bool m_hasModel = false; + + // Live model + animation state. m_scene owns the parsed MDL data so the + // renderer can re-skin per frame; m_player drives bone matrices over + // time; m_animTimer advances the player at ~60 Hz; m_clock measures + // delta-time between ticks for smooth playback regardless of timer + // jitter. + MdlScene m_scene; + MdlAnimationPlayer m_player; + QTimer m_animTimer; + QElapsedTimer m_clock; + + QPointF m_lastMousePos; + Qt::MouseButtons m_pressedButtons; +}; + +#endif diff --git a/prolog/last_dirs.pl b/prolog/last_dirs.pl deleted file mode 100644 index ec4a1e7..0000000 --- a/prolog/last_dirs.pl +++ /dev/null @@ -1,44 +0,0 @@ -:-asserta(g_indir('/tmp/in')). -:-asserta(g_outdir('/tmp/out')). -:-asserta(g_logfile('cm-qt.log')). -:-asserta(g_pattern('*.mdl')). -:-asserta(g_small_log('cm-qt_summaru.log')). -:-asserta(g_user_option(classification,character)). -:-asserta(g_user_option(snap,none)). -:-asserta(g_user_option(tvert_snap,no)). -:-asserta(g_user_option(shadow,default)). -:-asserta(g_user_option(repivot,none)). -:-asserta(g_user_option(allow_split,no)). -:-asserta(g_user_option(min_Size,4)). -:-asserta(g_user_option(use_Smoothed,use)). -:-asserta(g_user_option(split_Priority,concave)). -:-asserta(g_user_option('pivots_below_z=0',disallow)). -:-asserta(g_user_option(move_bad_pivots,no)). -:-asserta(g_user_option(force_white,yes)). -:-asserta(g_user_option(do_water,no)). -:-asserta(g_user_option(water_key,water)). -:-asserta(g_user_option(dynamic_water,yes)). -:-asserta(g_user_option(wave_height,5)). -:-asserta(g_user_option(rotate_water,1)). -:-asserta(g_user_option(foliage,ignore)). -:-asserta(g_user_option(foliage_key,trefol)). -:-asserta(g_user_option(splotch,ignore)). -:-asserta(g_user_option(splotch_key,splotch)). -:-asserta(g_user_option(rotate_ground,no_change)). -:-asserta(g_user_option(chamfer,no_change)). -:-asserta(g_user_option(ground_key,ground)). -:-asserta(g_user_option(fix_overhangs,yes)). -:-asserta(g_user_option(map_aabb_material,no)). -:-asserta(g_user_option(map_aabb_from,0)). -:-asserta(g_user_option(map_aabb_to,0)). -:-asserta(g_user_option(slice,undo)). -:-asserta(g_user_option(slice_height,5.0)). -:-asserta(g_user_option(tile_raise,no)). -:-asserta(g_user_option(tile_raise_amount,1)). -:-asserta(g_user_option(tile_water,no_change)). -:-asserta(g_user_option(tile_ground,no_change)). -:-asserta(g_user_option(merge_by_bitmap,no)). -:-asserta(g_user_option(placeable_with_transparency,no)). -:-asserta(g_user_option(transparency_key,glass)). -:-asserta(g_user_option(invisible_mesh_cull,yes)). -:-asserta(g_user_option(render,default)). diff --git a/prolog_files.qrc b/prolog_files.qrc deleted file mode 100644 index fbdf409..0000000 --- a/prolog_files.qrc +++ /dev/null @@ -1,5 +0,0 @@ - - - prolog/last_dirs.pl - - diff --git a/renderer.cpp b/renderer.cpp new file mode 100644 index 0000000..5757ad1 --- /dev/null +++ b/renderer.cpp @@ -0,0 +1,706 @@ +#include "constants.h" +#include "renderer.h" +#include +#include +#include +#include +#include +#include +#include + +static const char *kVertexShader = R"glsl( +#version 330 core +layout(location=0) in vec3 aPos; +layout(location=1) in vec3 aNormal; +layout(location=2) in vec2 aUV; + +uniform mat4 uModel, uView, uProjection; +uniform mat3 uNormalMatrix; + +out vec3 vNormal, vWorldPos; +out vec2 vUV; + +void main() { + vec4 wp = uModel * vec4(aPos, 1.0); + vWorldPos = wp.xyz; + vNormal = normalize(uNormalMatrix * aNormal); + vUV = aUV; + gl_Position = uProjection * uView * wp; +} +)glsl"; + +static const char *kFragmentShader = R"glsl( +#version 330 core +in vec3 vNormal, vWorldPos; +in vec2 vUV; + +uniform vec3 uDiffuse, uAmbient, uSpecular; +uniform float uShininess; +uniform vec3 uLightDir, uLightColor, uViewPos; +uniform sampler2D uTexture; + +out vec4 FragColor; + +void main() { + vec3 normal = normalize(vNormal); + if (!gl_FrontFacing) normal = -normal; + + vec3 ambient = uAmbient * uLightColor * 0.3; + + float diff = max(dot(normal, -uLightDir), 0.0); + vec3 diffuse = diff * uDiffuse * uLightColor; + + vec3 viewDir = normalize(uViewPos - vWorldPos); + vec3 halfDir = normalize(-uLightDir + viewDir); + float spec = pow(max(dot(normal, halfDir), 0.0), max(uShininess, 1.0)); + vec3 specular = spec * uSpecular * uLightColor; + + vec3 result = ambient + diffuse + specular; + + vec4 baseColor = texture(uTexture, vUV); + // Alpha-cutout: many NWN textures bake binary masks into the alpha + // channel (lion mane sprites, fox tail tufts, foliage, etc.). Without + // discarding the transparent fragments they still write to the depth + // buffer and occlude the solid geometry behind them, producing + // "gray blob" silhouettes around hairy/fuzzy parts. + if (baseColor.a < 0.1) discard; + FragColor = vec4(result * baseColor.rgb, baseColor.a); +} +)glsl"; + +GLuint Renderer::compileShader(QOpenGLFunctions_3_3_Core *gl, GLenum type, const char *src) +{ + GLuint shader = gl->glCreateShader(type); + gl->glShaderSource(shader, 1, &src, nullptr); + gl->glCompileShader(shader); + + GLint ok = 0; + gl->glGetShaderiv(shader, GL_COMPILE_STATUS, &ok); + if (!ok) + { + char log[1024]; + gl->glGetShaderInfoLog(shader, sizeof(log), nullptr, log); + qWarning() << "Shader compile error:" << log; + gl->glDeleteShader(shader); + return 0; + } + return shader; +} + +GLuint Renderer::linkProgram(QOpenGLFunctions_3_3_Core *gl, GLuint vert, GLuint frag) +{ + GLuint prog = gl->glCreateProgram(); + gl->glAttachShader(prog, vert); + gl->glAttachShader(prog, frag); + gl->glLinkProgram(prog); + + GLint ok = 0; + gl->glGetProgramiv(prog, GL_LINK_STATUS, &ok); + if (!ok) + { + char log[1024]; + gl->glGetProgramInfoLog(prog, sizeof(log), nullptr, log); + qWarning() << "Program link error:" << log; + gl->glDeleteProgram(prog); + return 0; + } + gl->glDeleteShader(vert); + gl->glDeleteShader(frag); + return prog; +} + +void Renderer::initialize(QOpenGLFunctions_3_3_Core *gl) +{ + GLuint vs = compileShader(gl, GL_VERTEX_SHADER, kVertexShader); + GLuint fs = compileShader(gl, GL_FRAGMENT_SHADER, kFragmentShader); + if (vs && fs) + m_program = linkProgram(gl, vs, fs); + + if (m_program) + { + m_locModel = gl->glGetUniformLocation(m_program, "uModel"); + m_locView = gl->glGetUniformLocation(m_program, "uView"); + m_locProjection = gl->glGetUniformLocation(m_program, "uProjection"); + m_locNormalMatrix = gl->glGetUniformLocation(m_program, "uNormalMatrix"); + m_locDiffuse = gl->glGetUniformLocation(m_program, "uDiffuse"); + m_locAmbient = gl->glGetUniformLocation(m_program, "uAmbient"); + m_locSpecular = gl->glGetUniformLocation(m_program, "uSpecular"); + m_locShininess = gl->glGetUniformLocation(m_program, "uShininess"); + m_locLightDir = gl->glGetUniformLocation(m_program, "uLightDir"); + m_locLightColor = gl->glGetUniformLocation(m_program, "uLightColor"); + m_locViewPos = gl->glGetUniformLocation(m_program, "uViewPos"); + m_locTexture = gl->glGetUniformLocation(m_program, "uTexture"); + } + + unsigned char white[] = {255, 255, 255, 255}; + gl->glGenTextures(1, &m_whiteTex); + gl->glBindTexture(GL_TEXTURE_2D, m_whiteTex); + gl->glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 1, 1, 0, + GL_RGBA, GL_UNSIGNED_BYTE, white); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + gl->glBindTexture(GL_TEXTURE_2D, 0); + + buildGrid(gl); +} + +void Renderer::destroyRenderNodes(QOpenGLFunctions_3_3_Core *gl, std::vector &nodes) +{ + for (auto &rn : nodes) { + if (rn.mesh) rn.mesh->destroy(gl); + if (rn.texture) rn.texture->destroy(gl); + } + nodes.clear(); +} + +void Renderer::shutdown(QOpenGLFunctions_3_3_Core *gl) +{ + destroyRenderNodes(gl, m_renderNodes); + destroyRenderNodes(gl, m_referenceNodes); + + if (m_gridMesh) { + m_gridMesh->destroy(gl); + m_gridMesh.reset(); + } + + if (m_whiteTex) { + gl->glDeleteTextures(1, &m_whiteTex); + m_whiteTex = 0; + } + + if (m_program) + { + gl->glDeleteProgram(m_program); + m_program = 0; + } +} + +void Renderer::buildGrid(QOpenGLFunctions_3_3_Core *gl) +{ + // 10m x 10m ground grid at Z=0, 1m spacing (NWN uses 10 units = 1 tile = 10m) + QVector verts; + QVector indices; + const float extent = 5.0f; // +/- 5m + const float step = 1.0f; + + auto addLine = [&](float x0, float y0, float z0, float x1, float y1, float z1) { + uint32_t base = static_cast(verts.size()); + Vertex v0{}, v1{}; + v0.pos[0] = x0; v0.pos[1] = y0; v0.pos[2] = z0; + v0.normal[0] = 0; v0.normal[1] = 0; v0.normal[2] = 1; + v1.pos[0] = x1; v1.pos[1] = y1; v1.pos[2] = z1; + v1.normal[0] = 0; v1.normal[1] = 0; v1.normal[2] = 1; + verts.append(v0); + verts.append(v1); + indices.append(base); + indices.append(base + 1); + }; + + for (float v = -extent; v <= extent + 0.01f; v += step) { + addLine(v, -extent, 0, v, extent, 0); + addLine(-extent, v, 0, extent, v, 0); + } + + m_gridMesh = std::make_unique(); + m_gridMesh->upload(gl, verts, indices); +} + +void Renderer::prepareNodeList(QOpenGLFunctions_3_3_Core *gl, const MdlScene &scene, + const QHash &boneWorld, + const QString &textureDir, + std::vector &target) +{ + int root = scene.rootIndex(); + if (root < 0) + return; + + // RAII swap m_textureDir for the duration of the upload so callers + // can't leak a reference scene's texture directory into subsequent + // main-scene operations (and vice versa). resolveTexturePath + // inside uploadNodeMesh consults m_textureDir; once the upload + // completes, RenderNode owns the GpuTexture handles directly and + // m_textureDir is no longer needed for these nodes. The guard's + // destructor restores the previous value even on exception unwind + // (e.g. allocation failure deep in GpuMesh::upload), so a failed + // upload can't quietly leave the renderer resolving every later + // texture against the wrong directory. + struct TexDirGuard { + QString *target; + QString saved; + ~TexDirGuard() { *target = std::move(saved); } + // Non-copyable / non-movable: a single scope owns the + // restore. Two guards over the same target would both restore + // on destruction and clobber each other; transferring + // ownership across scopes would defeat the RAII contract. + TexDirGuard(const TexDirGuard &) = delete; + TexDirGuard &operator=(const TexDirGuard &) = delete; + TexDirGuard(TexDirGuard &&) = delete; + TexDirGuard &operator=(TexDirGuard &&) = delete; + }; + TexDirGuard guard{&m_textureDir, m_textureDir}; + m_textureDir = textureDir; + + QMatrix4x4 identity; + QSet visited; + buildRenderNodes(gl, scene, root, identity, boneWorld, target, visited); +} + +void Renderer::prepareScene(QOpenGLFunctions_3_3_Core *gl, const MdlScene &scene, + const QHash &boneWorld, + const QString &textureDir) +{ + destroyRenderNodes(gl, m_renderNodes); + prepareNodeList(gl, scene, boneWorld, textureDir, m_renderNodes); +} + +void Renderer::prepareReferenceModel(QOpenGLFunctions_3_3_Core *gl, const MdlScene &scene, + const QHash &boneWorld, + const QString &textureDir) +{ + destroyRenderNodes(gl, m_referenceNodes); + prepareNodeList(gl, scene, boneWorld, textureDir, m_referenceNodes); + + // Reference nodes are uploaded once and never re-animated. Clear + // sceneNodeIdx so updateAnimatedMeshes (and any future code that + // dereferences scene.nodes()[rn.sceneNodeIdx]) can't accidentally + // index into the now-out-of-scope reference MdlScene. + for (auto &rn : m_referenceNodes) { + rn.sceneNodeIdx = -1; + rn.isSkin = false; + } + + m_showReference = true; +} + +void Renderer::clearReferenceModel(QOpenGLFunctions_3_3_Core *gl) +{ + destroyRenderNodes(gl, m_referenceNodes); + m_showReference = false; +} + +void Renderer::buildRenderNodes(QOpenGLFunctions_3_3_Core *gl, const MdlScene &scene, + int rootIdx, const QMatrix4x4 &rootParentWorld, + const QHash &boneWorld, + std::vector &target, + QSet &visited) +{ + // Iterative DFS on a heap-allocated worklist of (nodeIdx, + // parentWorld) pairs. Recursion blew the stack on flat MDLs whose + // parent-child graph chains thousands of nodes (each parented to + // the previous): a cycle guard prevents infinite recursion but + // does nothing about deep linear chains. Bounded now by heap + // size, not thread stack. + // + // Trade-off: classic recursion peaks at O(depth) frames on the + // native stack, while iterative-with-eager-child-push peaks at + // O(depth + max_sibling_fanout) entries on the heap (each entry + // is a `QPair`, ~72 bytes). For the typical NWN + // creature (~50 nodes, fanout < 10) peak heap is sub-kilobyte; + // for a pathological MDL with one parent of N children the + // worklist briefly holds N entries. We accept that โ€” the + // alternative was leaving the stack-overflow DoS open. Eliminating + // the wide-fanout cost would mean precomputing a flat preorder + // visit array at parse time, which is a bigger refactor with no + // current evidence of harm on real content. + QStack> stack; + stack.push({rootIdx, rootParentWorld}); + + while (!stack.isEmpty()) { + const auto [nodeIdx, parentWorld] = stack.pop(); + if (visited.contains(nodeIdx)) + continue; + visited.insert(nodeIdx); + + const MdlNode &node = scene.nodes()[nodeIdx]; + + // Prefer the animation player's world transform when available; + // fall back to the parent-walk product for nodes the player + // doesn't know about (e.g. when boneWorld is empty for tests). + QMatrix4x4 world; + auto bw = boneWorld.constFind(nodeIdx); + if (bw != boneWorld.constEnd()) + world = bw.value(); + else + world = parentWorld * makeLocalTransform(node); + + if (node.hasMesh() && node.render) { + // CLEANMODELS_SKIN=0 falls back to drawing skin nodes the + // same as trimeshes. Useful for A/B comparison against the + // pre-skinning code. + bool useSkinning = node.isSkin() && (qgetenv("CLEANMODELS_SKIN") != "0"); + + if (useSkinning) { + // Initial upload uses boneWorld (current animation + // frame at load time). Per-frame animation updates + // land in updateAnimatedMeshes() against sceneNodeIdx. + const QVector skinned = scene.skinnedVertsWith(nodeIdx, boneWorld); + uploadNodeMesh(gl, node, QMatrix4x4(), target, &skinned, nodeIdx); + } else { + // Record sceneNodeIdx for non-skin meshes too so + // updateAnimatedMeshes() can refresh their + // worldTransform each frame and danglymesh tails + // follow their parent bones. + uploadNodeMesh(gl, node, world, target, nullptr, nodeIdx); + } + } + + // Push children in reverse so DFS visits in declaration order + // (matches the previous recursive form's render order, which + // affects translucent-blend ordering). + const QVector &children = scene.childrenOf(nodeIdx); + for (int i = children.size() - 1; i >= 0; --i) + stack.push({children[i], world}); + } +} + +namespace { + +// Build smooth per-vertex normals when authored normals are unavailable +// (or were invalidated by overriding the vertex positions, e.g. by CPU +// skinning). Lives at file scope so both the upload-time topology +// builder and the per-frame position recompute share one definition. +QVector computeSmoothNormals(const MdlNode &node, + const QVector &verts) +{ + QVector out; + if (verts.isEmpty()) + return out; + out.resize(verts.size(), QVector3D(0, 0, 0)); + for (const auto &face : node.faces) { + // Reject negative indices as well as past-the-end ones. A + // crafted MDL can author `face.verts[i] = -1`, which passes a + // bare `>= verts.size()` check (e.g. -1 >= N is false) and + // then becomes both an OOB read in `verts[-1]` and an OOB + // write in `out[-1]` โ€” the second is a genuine memory- + // corruption primitive against Qt's heap. The expanded-vert + // path (appendExpandedVerts) already guards both bounds; this + // site is matched to the same contract. + if (face.verts[0] < 0 || face.verts[0] >= verts.size() || + face.verts[1] < 0 || face.verts[1] >= verts.size() || + face.verts[2] < 0 || face.verts[2] >= verts.size()) + continue; + QVector3D e1 = verts[face.verts[1]] - verts[face.verts[0]]; + QVector3D e2 = verts[face.verts[2]] - verts[face.verts[0]]; + QVector3D fn = QVector3D::crossProduct(e1, e2); + out[face.verts[0]] += fn; + out[face.verts[1]] += fn; + out[face.verts[2]] += fn; + } + for (auto &n : out) + n.normalize(); + return out; +} + +// Shared per-face vertex builder for both buildUploadMesh and +// rebuildExpandedVerts. Splits responsibility cleanly: callers decide +// whether they also need a fresh index buffer, this function is purely +// "give me the per-face Vertex stream". +void appendExpandedVerts(const MdlNode &node, + const QVector *vertsOverride, + QVector &vertices) +{ + vertices.clear(); + + const QVector &verts = vertsOverride ? *vertsOverride : node.verts; + + // Authored normals are only trustworthy when the vertex positions + // they were exported against are still in use. With vertsOverride + // we've already morphed the mesh (CPU skinning, danglymesh, etc.); + // the authored normals would be referencing the bind-pose + // positions, so we throw them out and recompute from the new + // verts. Without an override the per-vertex count match is the + // authoring tool's signal that authored normals belong to these + // verts. + bool hasNormals = !vertsOverride && (node.normals.size() == verts.size()); + QVector smoothNormals = hasNormals + ? QVector{} + : computeSmoothNormals(node, verts); + + vertices.reserve(static_cast(node.faces.size()) * 3); + + for (const auto &face : node.faces) + { + for (int i = 0; i < 3; ++i) + { + Vertex v{}; + int vi = face.verts[i]; + if (vi >= 0 && vi < verts.size()) + { + const auto &p = verts[vi]; + v.pos[0] = p.x(); v.pos[1] = p.y(); v.pos[2] = p.z(); + } + + if (hasNormals && vi >= 0 && vi < node.normals.size()) + { + const auto &n = node.normals[vi]; + v.normal[0] = n.x(); v.normal[1] = n.y(); v.normal[2] = n.z(); + } + else if (!smoothNormals.isEmpty() && vi >= 0 && vi < smoothNormals.size()) + { + const auto &n = smoothNormals[vi]; + v.normal[0] = n.x(); v.normal[1] = n.y(); v.normal[2] = n.z(); + } + else + { + v.normal[0] = 0; v.normal[1] = 0; v.normal[2] = 1; + } + + int ui = face.uvs[i]; + if (ui >= 0 && ui < node.tverts.size()) + { + const auto &t = node.tverts[ui]; + v.uv[0] = t.x(); + v.uv[1] = t.y(); + } + + vertices.append(v); + } + } +} + +} // namespace + +void Renderer::buildUploadMesh(const MdlNode &node, + const QVector *vertsOverride, + QVector &vertices, + QVector &indices) +{ + appendExpandedVerts(node, vertsOverride, vertices); + indices.resize(vertices.size()); + // Sequential 0..N-1: every face-corner vertex is unique because we + // emit them one-per-corner above (no welding by position/UV). + std::iota(indices.begin(), indices.end(), uint32_t{0}); +} + +void Renderer::rebuildExpandedVerts(const MdlNode &node, + const QVector &verts, + QVector &vertices) +{ + appendExpandedVerts(node, &verts, vertices); +} + +void Renderer::uploadNodeMesh(QOpenGLFunctions_3_3_Core *gl, const MdlNode &node, + const QMatrix4x4 &worldTransform, + std::vector &target, + const QVector *vertsOverride, + int sceneNodeIdx) +{ + QVector vertices; + QVector indices; + buildUploadMesh(node, vertsOverride, vertices, indices); + if (vertices.isEmpty()) + return; + + RenderNode rn; + rn.worldTransform = worldTransform; + rn.ambient = node.ambient; + rn.diffuse = node.diffuse; + rn.specular = node.specular; + rn.shininess = node.shininess; + rn.sceneNodeIdx = sceneNodeIdx; + rn.isSkin = (node.isSkin() && vertsOverride != nullptr); + rn.mesh = std::make_unique(); + rn.mesh->upload(gl, vertices, indices); + + if (!node.bitmap.isEmpty() && node.bitmap.toLower() != "null") { + QString texPath = resolveTexturePath(node.bitmap); + if (!texPath.isEmpty()) { + auto tex = std::make_unique(); + if (tex->loadFromFile(gl, texPath)) + rn.texture = std::move(tex); + else + qWarning() << "Failed to load texture:" << texPath; + } + } + + target.push_back(std::move(rn)); +} + +void Renderer::updateAnimatedMeshes(QOpenGLFunctions_3_3_Core *gl, + const MdlScene &scene, + const QHash &boneWorld) +{ + if (!gl) + return; + const QVector &sceneNodes = scene.nodes(); + QVector vertices; + for (auto &rn : m_renderNodes) { + if (rn.sceneNodeIdx < 0 || rn.sceneNodeIdx >= sceneNodes.size() || !rn.mesh) + continue; + + const MdlNode &node = sceneNodes[rn.sceneNodeIdx]; + + if (rn.isSkin) { + // Skin meshes: re-skin verts in place. Their world transform + // is identity (verts live in model space after skinning). + const QVector skinned = + scene.skinnedVertsWith(rn.sceneNodeIdx, boneWorld); + if (skinned.isEmpty()) + continue; + // Per-frame fast path: rebuilds verts only โ€” index buffer + // already lives on the GPU from upload time. + rebuildExpandedVerts(node, skinned, vertices); + if (vertices.isEmpty()) + continue; + rn.mesh->updateVertices(gl, vertices); + } else { + // Non-skin meshes (trimesh / danglymesh / aabb): keep the + // mesh data static and just refresh the world transform from + // the animation player's per-bone matrices. Without this, + // separate parts like the squirrel's danglymesh tail stay + // pinned to their bind-pose world position even when their + // parent bone is animating. + auto it = boneWorld.constFind(rn.sceneNodeIdx); + if (it != boneWorld.constEnd()) + rn.worldTransform = it.value(); + } + } +} + +QString Renderer::resolveTexturePath(const QString &bitmap) const +{ + if (m_textureDir.isEmpty()) + return {}; + + QDir dir(m_textureDir); + static const QStringList extensions = {"tga", "dds", "png", "bmp", "jpg", "jpeg"}; + + for (const auto &ext : extensions) { + QString candidate = dir.filePath(bitmap + "." + ext); + if (QFileInfo::exists(candidate)) + return candidate; + } + + QStringList entries = dir.entryList(QDir::Files); + QString lowerBitmap = bitmap.toLower(); + for (const auto &entry : entries) { + QString baseName = QFileInfo(entry).completeBaseName().toLower(); + if (baseName == lowerBitmap) { + QString suffix = QFileInfo(entry).suffix().toLower(); + if (extensions.contains(suffix)) + return dir.filePath(entry); + } + } + + return {}; +} + +void Renderer::renderNodes(QOpenGLFunctions_3_3_Core *gl, + const std::vector &nodes, bool wireframe, + const MaterialOverride &matOverride) +{ + for (const auto &rn : nodes) + { + gl->glUniformMatrix4fv(m_locModel, 1, GL_FALSE, rn.worldTransform.constData()); + + QMatrix3x3 normalMat = rn.worldTransform.normalMatrix(); + gl->glUniformMatrix3fv(m_locNormalMatrix, 1, GL_FALSE, normalMat.constData()); + + if (matOverride.active) { + gl->glUniform3f(m_locDiffuse, matOverride.diffuse.x(), matOverride.diffuse.y(), matOverride.diffuse.z()); + gl->glUniform3f(m_locAmbient, matOverride.ambient.x(), matOverride.ambient.y(), matOverride.ambient.z()); + gl->glUniform3f(m_locSpecular, 0.0f, 0.0f, 0.0f); + gl->glUniform1f(m_locShininess, 1.0f); + } else { + gl->glUniform3f(m_locDiffuse, rn.diffuse.x(), rn.diffuse.y(), rn.diffuse.z()); + gl->glUniform3f(m_locAmbient, rn.ambient.x(), rn.ambient.y(), rn.ambient.z()); + gl->glUniform3f(m_locSpecular, rn.specular.x(), rn.specular.y(), rn.specular.z()); + gl->glUniform1f(m_locShininess, rn.shininess); + } + + if (!wireframe && !matOverride.active && rn.texture && rn.texture->isValid()) + rn.texture->bind(gl, 0); + else { + gl->glActiveTexture(GL_TEXTURE0); + gl->glBindTexture(GL_TEXTURE_2D, m_whiteTex); + } + gl->glUniform1i(m_locTexture, 0); + + if (wireframe) + rn.mesh->drawWireframe(gl); + else + rn.mesh->draw(gl); + } +} + +void Renderer::render(QOpenGLFunctions_3_3_Core *gl, const Camera &camera) +{ + gl->glClearColor(ViewportColor::BgR, ViewportColor::BgG, ViewportColor::BgB, 1.0f); + gl->glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + + if (!m_program) + return; + + gl->glEnable(GL_DEPTH_TEST); + gl->glDepthFunc(GL_LESS); + gl->glEnable(GL_CULL_FACE); + gl->glCullFace(GL_BACK); + gl->glEnable(GL_BLEND); + gl->glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + gl->glEnable(GL_MULTISAMPLE); + + gl->glUseProgram(m_program); + + QMatrix4x4 view = camera.viewMatrix(); + QMatrix4x4 proj = camera.projectionMatrix(); + QVector3D camPos = camera.position(); + + gl->glUniformMatrix4fv(m_locView, 1, GL_FALSE, view.constData()); + gl->glUniformMatrix4fv(m_locProjection, 1, GL_FALSE, proj.constData()); + gl->glUniform3f(m_locViewPos, camPos.x(), camPos.y(), camPos.z()); + + QVector3D lightDir = QVector3D(-0.5f, -1.0f, -0.5f).normalized(); + gl->glUniform3f(m_locLightDir, lightDir.x(), lightDir.y(), lightDir.z()); + gl->glUniform3f(m_locLightColor, 1.0f, 1.0f, 1.0f); + + // Ground grid + if (m_showGrid && m_gridMesh && m_gridMesh->isValid()) + { + QMatrix4x4 identity; + gl->glUniformMatrix4fv(m_locModel, 1, GL_FALSE, identity.constData()); + QMatrix3x3 normalMat = identity.normalMatrix(); + gl->glUniformMatrix3fv(m_locNormalMatrix, 1, GL_FALSE, normalMat.constData()); + + gl->glUniform3f(m_locDiffuse, ViewportColor::GridR, ViewportColor::GridG, ViewportColor::GridB); + gl->glUniform3f(m_locAmbient, ViewportColor::GridR, ViewportColor::GridG, ViewportColor::GridB); + gl->glUniform3f(m_locSpecular, 0.0f, 0.0f, 0.0f); + gl->glUniform1f(m_locShininess, 1.0f); + + gl->glActiveTexture(GL_TEXTURE0); + gl->glBindTexture(GL_TEXTURE_2D, m_whiteTex); + gl->glUniform1i(m_locTexture, 0); + + gl->glBindVertexArray(m_gridMesh->vao()); + gl->glDrawElements(GL_LINES, m_gridMesh->indexCount(), GL_UNSIGNED_INT, nullptr); + gl->glBindVertexArray(0); + } + + if (m_showReference && !m_referenceNodes.empty()) + { + gl->glDisable(GL_CULL_FACE); + gl->glEnable(GL_BLEND); + gl->glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + gl->glDepthMask(GL_FALSE); + + MaterialOverride refMat; + refMat.active = true; + refMat.diffuse = {ViewportColor::RefR, ViewportColor::RefG, ViewportColor::RefB}; + refMat.ambient = refMat.diffuse; + renderNodes(gl, m_referenceNodes, true, refMat); + + gl->glDepthMask(GL_TRUE); + gl->glEnable(GL_CULL_FACE); + } + + // Main scene + if (!m_renderNodes.empty()) + { + if (m_wireframe) + gl->glDisable(GL_CULL_FACE); + renderNodes(gl, m_renderNodes, m_wireframe); + if (m_wireframe) + gl->glEnable(GL_CULL_FACE); + } + + gl->glUseProgram(0); +} diff --git a/renderer.h b/renderer.h new file mode 100644 index 0000000..0db4503 --- /dev/null +++ b/renderer.h @@ -0,0 +1,169 @@ +#ifndef RENDERER_H +#define RENDERER_H + +#include "camera.h" +#include "gpumesh.h" +#include "gputexture.h" +#include "mdlscene.h" +#include +#include +#include +#include +#include +#include +#include +#include + +struct RenderNode { + std::unique_ptr mesh; + std::unique_ptr texture; + QMatrix4x4 worldTransform; + QVector3D ambient; + QVector3D diffuse; + QVector3D specular; + float shininess = 1.0f; + + // Animation hookup. For skin meshes, sceneNodeIdx points at the source + // MdlNode so the renderer can re-skin per frame using new bone matrices + // without re-uploading the texture or rebuilding the topology. -1 means + // "static โ€” never animated". + int sceneNodeIdx = -1; + bool isSkin = false; +}; + +class Renderer +{ +public: + Renderer() = default; + + void initialize(QOpenGLFunctions_3_3_Core *gl); + void shutdown(QOpenGLFunctions_3_3_Core *gl); + + // Build the primary render-node list from `scene`. `boneWorld` supplies + // the world transform for every node, keyed by node index in + // MdlScene::nodes() (typically from a freshly-set-up + // MdlAnimationPlayer at its current frame); skin meshes are skinned + // against it, non-skin meshes use it for their world transform. Empty + // hash falls back to bind-pose world transforms. + void prepareScene(QOpenGLFunctions_3_3_Core *gl, const MdlScene &scene, + const QHash &boneWorld = {}, + const QString &textureDir = QString()); + // Same as prepareScene but populates the reference (overlay) node list. + // Reference nodes are uploaded once and never re-animated, so they don't + // retain a sceneNodeIdx โ€” the sourcing MdlScene can safely go out of + // scope after this call returns. + void prepareReferenceModel(QOpenGLFunctions_3_3_Core *gl, const MdlScene &scene, + const QHash &boneWorld = {}, + const QString &textureDir = QString()); + void clearReferenceModel(QOpenGLFunctions_3_3_Core *gl); + void render(QOpenGLFunctions_3_3_Core *gl, const Camera &camera); + + // Re-skin every primary RenderNode marked isSkin against the supplied + // scene + bone-world map (typically driven by an MdlAnimationPlayer) + // and rewrite its VBO contents. Non-skin meshes get their world + // transform refreshed from `boneWorld[sceneNodeIdx]` so danglymesh + // tails and other separate parts follow their animated parent bones. + // Topology and textures are preserved. + void updateAnimatedMeshes(QOpenGLFunctions_3_3_Core *gl, const MdlScene &scene, + const QHash &boneWorld); + + void setWireframe(bool on) { m_wireframe = on; } + bool wireframe() const { return m_wireframe; } + bool showReference() const { return m_showReference; } + void setShowGrid(bool on) { m_showGrid = on; } + bool showGrid() const { return m_showGrid; } + +private: + GLuint m_program = 0; + GLint m_locModel = -1; + GLint m_locView = -1; + GLint m_locProjection = -1; + GLint m_locNormalMatrix = -1; + GLint m_locDiffuse = -1; + GLint m_locAmbient = -1; + GLint m_locSpecular = -1; + GLint m_locShininess = -1; + GLint m_locLightDir = -1; + GLint m_locLightColor = -1; + GLint m_locViewPos = -1; + GLint m_locTexture = -1; + + GLuint m_whiteTex = 0; + bool m_wireframe = false; + bool m_showReference = false; + bool m_showGrid = true; + std::vector m_renderNodes; + std::vector m_referenceNodes; + std::unique_ptr m_gridMesh; + QString m_textureDir; + + GLuint compileShader(QOpenGLFunctions_3_3_Core *gl, GLenum type, const char *src); + GLuint linkProgram(QOpenGLFunctions_3_3_Core *gl, GLuint vert, GLuint frag); + void destroyRenderNodes(QOpenGLFunctions_3_3_Core *gl, std::vector &nodes); + + // Shared prologue for prepareScene / prepareReferenceModel: sets + // m_textureDir for the duration of the upload (so uploadNodeMesh's + // resolveTexturePath finds the right textures), resolves the scene + // root, and dispatches buildRenderNodes with a fresh visited set, + // restoring the previous m_textureDir on exit. Encapsulating the + // save/restore here means callers can't forget to restore it, + // which previously meant prepareReferenceModel had a brittle + // open-coded dance that would corrupt the main scene's + // texture-resolution context if it threw or returned early. + void prepareNodeList(QOpenGLFunctions_3_3_Core *gl, const MdlScene &scene, + const QHash &boneWorld, + const QString &textureDir, + std::vector &target); + // Iterative DFS over the scene's child-adjacency graph + // (childrenOf) rooted at `rootIdx`. `visited` short-circuits + // cycles in that graph: a malformed MDL whose parent-name links + // form a loop (two nodes claiming each other as parent, or any + // longer cycle) would otherwise re-push the same node indices + // onto the worklist forever and grow it without bound. The + // explicit worklist also keeps deep linear chains bounded by + // heap size instead of thread stack size. `visited` is threaded + // through rather than held as state so reference + main scenes + // keep independent visit sets. + void buildRenderNodes(QOpenGLFunctions_3_3_Core *gl, const MdlScene &scene, + int rootIdx, const QMatrix4x4 &rootParentWorld, + const QHash &boneWorld, + std::vector &target, + QSet &visited); + // When `vertsOverride` is non-null, those positions are used instead of + // node.verts and authored normals are discarded (face normals get + // regenerated to match the overridden geometry). Used for skin nodes, + // where the verts come from CPU skinning in model space. + void uploadNodeMesh(QOpenGLFunctions_3_3_Core *gl, const MdlNode &node, + const QMatrix4x4 &worldTransform, + std::vector &target, + const QVector *vertsOverride = nullptr, + int sceneNodeIdx = -1); + // Initial-upload path: per-face triangle-corner Vertex entries plus + // a sequential index buffer [0, 1, ..., 3F-1]. With `vertsOverride` + // non-null we treat those positions as authoritative and regenerate + // smooth normals from them (authored normals only line up with the + // authored verts they were exported with). + void buildUploadMesh(const MdlNode &node, + const QVector *vertsOverride, + QVector &outVerts, + QVector &outIndices); + // Per-frame fast path: rebuilds vertices only โ€” UVs, positions, and + // normals โ€” for an already-uploaded mesh whose index buffer (a + // sequential 0..3F-1 from the upload) is unchanged. Always called + // with vertsOverride from CPU skinning, so smooth normals get + // regenerated to match the new positions. + void rebuildExpandedVerts(const MdlNode &node, + const QVector &verts, + QVector &outVerts); + struct MaterialOverride { + QVector3D diffuse, ambient; + bool active; + }; + void renderNodes(QOpenGLFunctions_3_3_Core *gl, const std::vector &nodes, + bool wireframe, const MaterialOverride &matOverride = {QVector3D(), QVector3D(), false}); + void buildGrid(QOpenGLFunctions_3_3_Core *gl); + + QString resolveTexturePath(const QString &bitmap) const; +}; + +#endif