diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1d6bb802..5af85b07 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,9 +6,45 @@ on: pull_request: branches: [main, master] workflow_dispatch: + inputs: + sentry_crash_test: + description: "After building, run --sentry-crash-test to send a real crash to Sentry (verifies symbolication)" + type: boolean + default: false jobs: + # Single source of truth for the build tag used in artifact names. + # tag = v-, where the short hash is the + # SAME `git rev-parse --short HEAD` the app embeds (TEXTURELAB_BUILD_HASH), so + # a build file name matches the version shown in-app / in screenshots. + version: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.v.outputs.tag }} + steps: + - uses: actions/checkout@v4 + - name: Compute build tag + id: v + run: | + # tag = v-, where is the numeric + # project() version + optional suffix — the same string the app embeds + # as TEXTURELAB_VERSION, so file names match the in-app version. + NUM=$(grep -oP 'project\(texturelab VERSION \K[0-9.]+' src/texturelab/CMakeLists.txt) + SUFFIX=$(grep -oP 'set\(TEXTURELAB_VERSION_SUFFIX "\K[^"]*' src/texturelab/CMakeLists.txt) + echo "tag=v${NUM}${SUFFIX}-$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + + # Fails the build if inline widget stylesheets or hardcoded QColor literals + # creep into the UI/rendering code (they'd bypass the theme system and break + # --dev-theme hot-reload). See scripts/check-theme-hygiene.sh + UI_DESIGN_SYSTEM_PRD.md. + theme-hygiene: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check theme hygiene + run: bash scripts/check-theme-hygiene.sh + build-linux: + needs: version # runs-on: ubuntu-20.04 runs-on: ubuntu-22.04 @@ -23,7 +59,7 @@ jobs: - name: Install Qt6 uses: jurplel/install-qt-action@v4 with: - version: "6.7.0" + version: "6.7.3" modules: "qtshadertools" dir: "${{ github.workspace }}/Qt" cache: true @@ -38,14 +74,43 @@ jobs: libxkbcommon-dev \ libvulkan-dev \ libxcb-cursor0 \ - libxcb-shape0-dev + libxcb-shape0-dev \ + libcurl4-openssl-dev - name: Configure CMake - run: cmake -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release + run: cmake -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=RelWithDebInfo -DSENTRY_BACKEND=crashpad -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" - name: Build run: cmake --build build --target texturelab --parallel $(nproc) + - name: Upload debug symbols to Sentry + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + run: | + curl -sL https://sentry.io/get-cli/ | bash + # Upload the FULL, unstripped binary. It contains debug info AND the + # .eh_frame unwind info (CFI) that Sentry needs to walk a minidump's + # stack. objcopy --only-keep-debug drops the unwind info, which left + # crashes unsymbolicated ("debug information files are missing"). + sentry-cli debug-files upload --include-sources \ + build/src/texturelab/texturelab + # Strip the shipped copy to shrink the AppImage. The GNU build-id + # (== Sentry Debug ID) survives stripping, so the DIF we just + # uploaded still matches the binary that ships and crashes. + strip build/src/texturelab/texturelab + + - name: Sentry crash test (manual) + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sentry_crash_test == 'true' }} + run: | + export QT_QPA_PLATFORM=offscreen + # Crashes before the main window opens; crashpad_handler (copied next to + # the binary at build time) uploads the minidump via the built-in DSN. + build/src/texturelab/texturelab --sentry-crash-test || true + echo "waiting for crashpad to upload the minidump..." + sleep 25 + - name: Install LinuxDeploy uses: miurahr/install-linuxdeploy-action@v1 with: @@ -56,9 +121,19 @@ jobs: APPIMAGE_EXTRACT_AND_RUN: 1 DEPLOY_STDCXX: 1 run: | - export QMAKE=${{ github.workspace }}/Qt/Qt/6.7.0/gcc_64/bin/qmake - export PATH=${{ github.workspace }}/Qt/Qt/6.7.0/gcc_64/bin:$PATH - export LD_LIBRARY_PATH=${{ github.workspace }}/Qt/Qt/6.7.0/gcc_64/lib:$LD_LIBRARY_PATH + export QMAKE=${{ github.workspace }}/Qt/Qt/6.7.3/gcc_64/bin/qmake + export PATH=${{ github.workspace }}/Qt/Qt/6.7.3/gcc_64/bin:$PATH + export LD_LIBRARY_PATH=${{ github.workspace }}/Qt/Qt/6.7.3/gcc_64/lib:$LD_LIBRARY_PATH + + # linuxdeploy's qt plugin deploys *every* sqldriver plugin it finds and + # hard-fails when one has an unresolvable dependency. Qt ships + # libqsqlmimer.so, whose libmimerapi.so is a proprietary Mimer SQL + # client that isn't on the runner ("ERROR: Could not find dependency: + # libmimerapi.so"). We only ever open QSQLITE (src/catalog/database.cpp), + # so keep libqsqlite.so and drop the rest — that also stops us shipping + # the GPL libmysqlclient the MySQL driver drags in. + QT_SQLDRIVERS=${{ github.workspace }}/Qt/Qt/6.7.3/gcc_64/plugins/sqldrivers + find "$QT_SQLDRIVERS" -name 'libqsql*.so' ! -name 'libqsqlite.so' -delete # Create desktop file cat > texturelab.desktop << 'EOF' @@ -73,6 +148,11 @@ jobs: # Create a placeholder icon (256x256 PNG with "TL" text) cp src/icons/logo.png texturelab.png + # Bundle crashpad_handler alongside the main binary + mkdir -p AppDir/usr/bin + cp build/src/texturelab/crashpad_handler AppDir/usr/bin/ || \ + cp build/_deps/sentry-build/crashpad_build/handler/crashpad_handler AppDir/usr/bin/ + linuxdeploy-x86_64.AppImage \ --appdir AppDir \ --executable build/src/texturelab/texturelab \ @@ -81,14 +161,20 @@ jobs: --plugin qt \ --output appimage + - name: Rename Linux artifact + run: | + APP=$(ls *.AppImage | head -1) + mv "$APP" "texturelab-linux-${{ needs.version.outputs.tag }}.AppImage" + - name: Upload Linux artifact uses: actions/upload-artifact@v4 with: - name: texturelab-linux - path: "*.AppImage" + name: texturelab-linux-${{ needs.version.outputs.tag }} + path: "texturelab-linux-${{ needs.version.outputs.tag }}.AppImage" build-windows: - runs-on: windows-latest + needs: version + runs-on: windows-2022 steps: - name: Checkout repository @@ -101,32 +187,81 @@ jobs: - name: Install Qt6 uses: jurplel/install-qt-action@v4 with: - version: "6.7.0" + version: "6.7.3" arch: "win64_msvc2019_64" modules: "qtshadertools" dir: "${{ github.workspace }}/Qt" cache: true - name: Configure CMake - run: cmake -B build -G "Visual Studio 17 2022" -A x64 -DCMAKE_BUILD_TYPE=Release + run: cmake -B build -G "Visual Studio 17 2022" -A x64 -DSENTRY_BACKEND=crashpad -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" - name: Build run: cmake --build build --target texturelab --config Release --parallel + - name: Upload debug symbols to Sentry + shell: pwsh + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + run: | + Invoke-WebRequest -Uri "https://github.com/getsentry/sentry-cli/releases/latest/download/sentry-cli-Windows-x86_64.exe" -OutFile "sentry-cli.exe" + $exe = "build\src\texturelab\Release\texturelab.exe" + $pdb = "build\src\texturelab\Release\texturelab.pdb" + if (-not (Test-Path $pdb)) { Write-Error "texturelab.pdb not found — build produced no debug info; Sentry cannot symbolicate."; exit 1 } + # Log the Debug IDs. The exe's Debug ID (from its CodeView record) MUST + # be non-null and match the pdb, or crash minidumps stay unsymbolicated. + Write-Host "== exe Debug ID =="; .\sentry-cli.exe debug-files check $exe + Write-Host "== pdb Debug ID =="; .\sentry-cli.exe debug-files check $pdb + .\sentry-cli.exe debug-files upload --include-sources "build\src\texturelab\Release\" + - name: Deploy Qt dependencies + shell: pwsh + run: | + New-Item -ItemType Directory -Path deploy + Copy-Item "build\src\texturelab\Release\texturelab.exe" deploy\ + & "${{ github.workspace }}\Qt\Qt\6.7.3\msvc2019_64\bin\windeployqt.exe" "deploy\texturelab.exe" --release --no-translations + $handler = @( + "build\src\texturelab\Release\crashpad_handler.exe", + "build\src\texturelab\crashpad_handler.exe", + "build\_deps\sentry-build\crashpad_build\handler\Release\crashpad_handler.exe" + ) | Where-Object { Test-Path $_ } | Select-Object -First 1 + if ($handler) { Copy-Item $handler deploy\ } else { Write-Warning "crashpad_handler.exe not found, skipping" } + + - name: Sentry crash test (manual) + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sentry_crash_test == 'true' }} + shell: pwsh + run: | + $env:QT_QPA_PLATFORM = "offscreen" + # Run the deployed bundle (Qt DLLs + crashpad_handler.exe alongside the + # exe). Crashes before the main window; crashpad uploads the minidump + # via the built-in DSN. Debug IDs match the PDB uploaded above. + $p = Start-Process -FilePath "deploy\texturelab.exe" -ArgumentList "--sentry-crash-test" -PassThru + if (-not $p.WaitForExit(60000)) { $p.Kill(); Write-Warning "timed out waiting for crash" } + else { Write-Host "app exited with code $($p.ExitCode) (crash expected)" } + Write-Host "waiting for crashpad to upload the minidump..." + Start-Sleep -Seconds 25 + + # Rename after the crash-test step above (which runs deploy\texturelab.exe) + # so the exe ships as texturelab-win-v-.exe alongside its DLLs. + - name: Rename Windows executable + shell: pwsh run: | - mkdir deploy - copy build\src\texturelab\Release\texturelab.exe deploy\ - ${{ github.workspace }}\Qt\Qt\6.7.0\msvc2019_64\bin\windeployqt.exe deploy\texturelab.exe --release --no-translations + Rename-Item "deploy\texturelab.exe" "texturelab-win-${{ needs.version.outputs.tag }}.exe" - name: Upload Windows artifact uses: actions/upload-artifact@v4 with: - name: texturelab-windows + name: texturelab-win-${{ needs.version.outputs.tag }} path: deploy/ build-macos: - runs-on: macos-latest + needs: version + # Qt 6.7.x still references AGL.framework, which modern macOS SDKs (Xcode 16+) + # removed — so we pin Xcode 15 (macOS 14 SDK, AGL present) on macos-14. Build + # universal x86_64+arm64 so it runs on both Intel and Apple Silicon Macs. + runs-on: macos-14 steps: - name: Checkout repository @@ -136,22 +271,159 @@ jobs: fetch-depth: 0 fetch-tags: true + - name: Select Xcode 15 (macOS 14 SDK still ships AGL.framework) + run: | + XC=$(ls -d /Applications/Xcode_15*.app 2>/dev/null | sort -V | tail -1) + if [ -z "$XC" ]; then echo "No Xcode 15.x found on runner"; ls -d /Applications/Xcode_*.app; exit 1; fi + echo "Using $XC" + sudo xcode-select -s "$XC/Contents/Developer" + xcodebuild -version + - name: Install Qt6 uses: jurplel/install-qt-action@v4 with: - version: "6.7.0" + version: "6.7.3" modules: "qtshadertools" dir: "${{ github.workspace }}/Qt" cache: true - name: Configure CMake - run: cmake -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release + run: | + # Crashpad's CMake locates the Mach mig defs at + # ${CMAKE_OSX_SYSROOT}/usr/include/mach/exc.defs. If CMAKE_OSX_SYSROOT + # is empty it resolves to /usr/include/mach (absent on modern macOS) + # and configure fails. Point it explicitly at the SDK, which ships them. + SDKROOT=$(xcrun --show-sdk-path) + echo "Using SDK: $SDKROOT" + cmake -B build -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_OSX_ARCHITECTURES="x86_64;arm64" -DCMAKE_OSX_SYSROOT="$SDKROOT" -DTEXTURELAB_SENTRY_DSN="${{ secrets.SENTRY_DSN }}" - name: Build run: cmake --build build --target texturelab --parallel $(sysctl -n hw.ncpu) + - name: Generate dSYM and upload to Sentry + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + run: | + dsymutil build/src/texturelab/texturelab.app/Contents/MacOS/texturelab \ + -o texturelab.dSYM + strip build/src/texturelab/texturelab.app/Contents/MacOS/texturelab + curl -sL https://sentry.io/get-cli/ | bash + sentry-cli debug-files upload --include-sources texturelab.dSYM + + - name: Sentry crash test (manual) + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sentry_crash_test == 'true' }} + run: | + export QT_QPA_PLATFORM=offscreen + # Crashpad (crashpad_handler bundled next to the binary) uploads the + # minidump out-of-process, symbolicated by the dSYM uploaded above. + build/src/texturelab/texturelab.app/Contents/MacOS/texturelab --sentry-crash-test || true + echo "waiting for crashpad to upload the minidump..." + sleep 25 + - name: Upload macOS artifact uses: actions/upload-artifact@v4 with: - name: texturelab-macos + name: texturelab-mac-${{ needs.version.outputs.tag }} path: build/src/texturelab/texturelab.app + + deploy: + needs: [version, build-linux, build-windows, build-macos] + if: always() + runs-on: ubuntu-latest + env: + TAG: ${{ needs.version.outputs.tag }} + steps: + - name: Download artifacts + if: ${{ needs.build-linux.result == 'success' || needs.build-windows.result == 'success' || needs.build-macos.result == 'success' }} + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Zip and upload to S3 + if: ${{ needs.build-linux.result == 'success' || needs.build-windows.result == 'success' || needs.build-macos.result == 'success' }} + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: us-east-2 + run: | + # Artifacts already arrive as texturelab--/ with the + # primary file (exe/AppImage) renamed. Here we just zip each bundle + # under the same texturelab--.zip name. + cd artifacts + + # Linux — single AppImage. + if [ -d "texturelab-linux-${TAG}" ]; then + zip -j "../texturelab-linux-${TAG}.zip" "texturelab-linux-${TAG}"/*.AppImage + fi + + # Windows — exe + sibling Qt DLLs + crashpad_handler. + if [ -d "texturelab-win-${TAG}" ]; then + (cd "texturelab-win-${TAG}" && zip -r "../../texturelab-win-${TAG}.zip" .) + fi + + # macOS — re-wrap the uploaded bundle contents as a proper .app, zip it. + if [ -d "texturelab-mac-${TAG}" ]; then + mkdir -p "wrap/texturelab.app" + cp -R "texturelab-mac-${TAG}/." "wrap/texturelab.app/" + (cd wrap && zip -r "../../texturelab-mac-${TAG}.zip" texturelab.app) + rm -rf wrap + fi + + cd .. + for f in texturelab-linux-${TAG}.zip texturelab-win-${TAG}.zip texturelab-mac-${TAG}.zip; do + if [ -f "$f" ]; then aws s3 cp "$f" s3://texturelab-nightlies/; fi + done + + - name: Post to Discord + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + BRANCH: ${{ github.ref_name }} + RUN_ID: ${{ github.run_id }} + REPO: ${{ github.repository }} + LINUX_RESULT: ${{ needs.build-linux.result }} + WINDOWS_RESULT: ${{ needs.build-windows.result }} + MACOS_RESULT: ${{ needs.build-macos.result }} + run: | + RUN_URL="https://github.com/$REPO/actions/runs/$RUN_ID" + S3_BASE="https://texturelab-nightlies.s3.us-east-2.amazonaws.com" + + status_emoji() { + case "$1" in + success) echo "✅" ;; + failure) echo "❌" ;; + cancelled) echo "⏹️" ;; + *) echo "⚠️" ;; + esac + } + + download_line() { + local result="$1" label="$2" url="$3" + if [[ "$result" == "success" ]]; then + echo "$(status_emoji "$result") **$label** — [Download]($url)" + else + echo "$(status_emoji "$result") **$label** — build $result" + fi + } + + if [[ "$LINUX_RESULT" == "success" && "$WINDOWS_RESULT" == "success" && "$MACOS_RESULT" == "success" ]]; then + TITLE="Build succeeded — $BRANCH @ $TAG" + COLOR=3066993 + else + TITLE="Build failed — $BRANCH @ $TAG" + COLOR=15158332 + fi + + DESCRIPTION="$(download_line "$LINUX_RESULT" "Linux" "$S3_BASE/texturelab-linux-$TAG.zip")\n$(download_line "$WINDOWS_RESULT" "Windows" "$S3_BASE/texturelab-win-$TAG.zip")\n$(download_line "$MACOS_RESULT" "macOS" "$S3_BASE/texturelab-mac-$TAG.zip")\n\n[View run]($RUN_URL)" + + curl -s -X POST "$DISCORD_WEBHOOK" \ + -H "Content-Type: application/json" \ + -d "{ + \"embeds\": [{ + \"title\": \"$TITLE\", + \"description\": \"$DESCRIPTION\", + \"color\": $COLOR, + \"url\": \"$RUN_URL\" + }] + }" diff --git a/.gitignore b/.gitignore index 6c8746dc..06a5cb00 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,8 @@ compile_commands.json CTestTestfile.cmake _deps -build/ \ No newline at end of file +build/ +build-sentry-test/ + +# Secrets — Sentry auth token, org/project slugs +.env \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index c289843b..c6c7c054 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,6 +2,29 @@ cmake_minimum_required(VERSION 3.10) project(qtcompleteapp VERSION 0.1 LANGUAGES CXX) +# sentry-native (Crashpad backend for out-of-process crash capture) +include(FetchContent) +FetchContent_Declare( + sentry + GIT_REPOSITORY https://github.com/getsentry/sentry-native.git + GIT_TAG 0.7.20 +) +set(SENTRY_BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) +if(WIN32) + set(SENTRY_BACKEND "crashpad" CACHE STRING "" FORCE) + set(SENTRY_TRANSPORT "winhttp" CACHE STRING "" FORCE) +elseif(APPLE) + # Crashpad: out-of-process minidumps with full thread stacks (symbolicated via + # the uploaded dSYM), same as Win/Linux. The inproc backend delivered crashes + # but with no stack frames on Apple Silicon, so reports were unsymbolicatable. + set(SENTRY_BACKEND "crashpad" CACHE STRING "" FORCE) + set(SENTRY_TRANSPORT "curl" CACHE STRING "" FORCE) +else() + set(SENTRY_BACKEND "crashpad" CACHE STRING "" FORCE) + set(SENTRY_TRANSPORT "curl" CACHE STRING "" FORCE) +endif() +FetchContent_MakeAvailable(sentry) + # Find Qt6 with GuiPrivate before adding subdirectories that need it find_package(Qt6 COMPONENTS Core Gui Widgets REQUIRED) @@ -16,6 +39,12 @@ set(BUILD_EXAMPLES OFF CACHE BOOL "Don't build ADS examples" FORCE) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/src/ads) # set_target_properties(qtadvanceddocking-qt6 PROPERTIES BUILD_STATIC TRUE) +# Theme / design-token system (single source of truth for colors, QSS, palette) +add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/src/theme) + +# Launcher data layer (index.db + thumbs.db). Headless, no widgets. +add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/src/catalog) + # Node Graph add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/src/nodegraph) @@ -28,3 +57,15 @@ add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/src/colorpicker) # Main App add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/src/texturelab) +# Unit tests (QtTest). Off in release builds; enable with -DTEXTURELAB_BUILD_TESTS=ON. +option(TEXTURELAB_BUILD_TESTS "Build the unit test suite" ON) +if(TEXTURELAB_BUILD_TESTS) + find_package(Qt6 COMPONENTS Test QUIET) + if(Qt6Test_FOUND) + enable_testing() + add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/tests) + else() + message(STATUS "Qt6 Test module not found — skipping unit tests") + endif() +endif() + diff --git a/README.md b/README.md index eadaa1f7..71cd178b 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,10 @@ install Qt 6 and required dependencies Note: Linux needs libmesa: https://doc.qt.io/qt-6/linux.html -sudo apt install build-essential libgl1-mesa-dev libxkbcommon-dev libvulkan-dev +sudo apt install build-essential libgl1-mesa-dev libxkbcommon-dev libvulkan-dev libcurl4-openssl-dev + +Note: libcurl4-openssl-dev is required by sentry-native (crash reporting). +Without it, CMake fails with "CURL: Required feature AsynchDNS is not found". ``` diff --git a/gh-build.sh b/gh-build.sh new file mode 100755 index 00000000..8b79f09e --- /dev/null +++ b/gh-build.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +source .env + +# Force gh to use the keyring login (which has repo scope). gh prefers the +# GH_TOKEN / GITHUB_TOKEN env vars over keyring auth, and the token exported +# from the shell only has read:packages scope, which 403s on workflow dispatch. +unset GH_TOKEN GITHUB_TOKEN + +REPO="njbrown/texturelab" +WORKFLOW="build.yml" +BRANCH="${1:-$(git rev-parse --abbrev-ref HEAD)}" + +echo "Triggering build for branch: $BRANCH" +gh workflow run "$WORKFLOW" --repo "$REPO" --ref "$BRANCH" + +echo "Waiting for run to start..." +sleep 3 + +RUN_ID=$(gh run list --repo "$REPO" --workflow "$WORKFLOW" --branch "$BRANCH" --limit 1 --json databaseId -q '.[0].databaseId') + +echo "Run ID: $RUN_ID" +echo "https://github.com/$REPO/actions/runs/$RUN_ID" + +if [[ "${2:-}" == "--watch" || "${1:-}" == "--watch" ]]; then + gh run watch "$RUN_ID" --repo "$REPO" +fi diff --git a/public/assets b/public/assets index 5996a27d..0aeea6c2 160000 --- a/public/assets +++ b/public/assets @@ -1 +1 @@ -Subproject commit 5996a27d943382fabeafb18217f9de212c62d420 +Subproject commit 0aeea6c2358099025e1a19eeeac01f62420bc6fb diff --git a/resources/icons/ads/close-button-disabled.svg b/resources/icons/ads/close-button-disabled.svg new file mode 100644 index 00000000..fb0cb58c --- /dev/null +++ b/resources/icons/ads/close-button-disabled.svg @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + Jemis Mali + + + + + image/svg+xml + + + + + + diff --git a/resources/icons/ads/close-button.svg b/resources/icons/ads/close-button.svg new file mode 100644 index 00000000..6ebbf382 --- /dev/null +++ b/resources/icons/ads/close-button.svg @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + Jemis Mali + + + + + image/svg+xml + + + + + + diff --git a/resources/icons/ads/detach-button-disabled.svg b/resources/icons/ads/detach-button-disabled.svg new file mode 100644 index 00000000..b94b0c3a --- /dev/null +++ b/resources/icons/ads/detach-button-disabled.svg @@ -0,0 +1,205 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Jemis Mali + + + + + image/svg+xml + + + + + + + + diff --git a/resources/icons/ads/detach-button.svg b/resources/icons/ads/detach-button.svg new file mode 100644 index 00000000..a1b6241c --- /dev/null +++ b/resources/icons/ads/detach-button.svg @@ -0,0 +1,175 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Jemis Mali + + + + + image/svg+xml + + + + + + + diff --git a/resources/icons/ads/maximize-button.svg b/resources/icons/ads/maximize-button.svg new file mode 100644 index 00000000..4dc165eb --- /dev/null +++ b/resources/icons/ads/maximize-button.svg @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + Jemis Mali + + + + + image/svg+xml + + + + + + diff --git a/resources/icons/ads/minimize-button-focused.svg b/resources/icons/ads/minimize-button-focused.svg new file mode 100644 index 00000000..c473a133 --- /dev/null +++ b/resources/icons/ads/minimize-button-focused.svg @@ -0,0 +1,2 @@ + + diff --git a/resources/icons/ads/restore-button.svg b/resources/icons/ads/restore-button.svg new file mode 100644 index 00000000..d6fd5059 --- /dev/null +++ b/resources/icons/ads/restore-button.svg @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Jemis Mali + + + + + image/svg+xml + + + + + + diff --git a/resources/icons/ads/tabs-menu-button.svg b/resources/icons/ads/tabs-menu-button.svg new file mode 100644 index 00000000..d5e2e2b4 --- /dev/null +++ b/resources/icons/ads/tabs-menu-button.svg @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + Jemis Mali + + + + + image/svg+xml + + + + + + diff --git a/resources/icons/ads/vs-pin-button-disabled.svg b/resources/icons/ads/vs-pin-button-disabled.svg new file mode 100644 index 00000000..a00d2b50 --- /dev/null +++ b/resources/icons/ads/vs-pin-button-disabled.svg @@ -0,0 +1,2 @@ + + diff --git a/resources/icons/ads/vs-pin-button-pinned-focused.svg b/resources/icons/ads/vs-pin-button-pinned-focused.svg new file mode 100644 index 00000000..ac05edb9 --- /dev/null +++ b/resources/icons/ads/vs-pin-button-pinned-focused.svg @@ -0,0 +1,2 @@ + + diff --git a/resources/icons/ads/vs-pin-button.svg b/resources/icons/ads/vs-pin-button.svg new file mode 100644 index 00000000..f9e36d01 --- /dev/null +++ b/resources/icons/ads/vs-pin-button.svg @@ -0,0 +1,2 @@ + + diff --git a/resources/qss/ads.qss.in b/resources/qss/ads.qss.in new file mode 100644 index 00000000..78ccc3bf --- /dev/null +++ b/resources/qss/ads.qss.in @@ -0,0 +1,107 @@ +/* + * Dock-system (Qt Advanced Docking System) color overrides. + * + * IMPORTANT: this is APPENDED to ADS's own default stylesheet by MainWindow + * (see mainwindow.cpp), not used on its own. ADS's default carries button icons, + * layout metrics, and auto-hide geometry -- we keep all of that and only override + * colors/borders here. Rules must match or exceed the default's selector + * specificity to win; since we're appended, equal specificity is enough. + * + * ADS's default is palette()-driven, so much already follows our theme (e.g. + * highlight == accent). These overrides replace the muddy derived-palette bits + * (inactive tab text = palette(dark), active-tab gradient) with crisp tokens. + * + * Hot-reloads with --dev-theme (edit + save; MainWindow re-applies on themeChanged). + */ + +/* ---- containers & areas ---- */ +/* Container is black so the only thing between panels is a single strong line + (the splitter), not a grey gutter -> no "double line" around panels. */ +ads--CDockContainerWidget { background: {{border.strong}}; } +/* Kill the default 1px splitter padding that otherwise shows a grey gap. */ +ads--CDockContainerWidget > QSplitter { padding: 0; } +ads--CDockAreaWidget { background: {{bg.panel}}; border: none; } +ads--CDockWidget { background: {{bg.panel}}; border: none; } + +/* Title bar = the strip holding the tabs + area buttons. Darker than the panel + so the active tab (panel-colored) reads as raised out of a near-black header. */ +ads--CDockAreaTitleBar { background: {{bg.elevated}}; border: none; } + +/* ---- dock widget tabs ---- */ +/* Inactive tabs sit in the dark header but stay a step lighter than it so they + don't blend in (header = bg.elevated, inactive tab = gray.800, active = bg.panel). */ +ads--CDockWidgetTab { + background: {{gray.800}}; + border: none; + border-right: 1px solid {{border.subtle}}; + padding: 5px 14px; +} +/* Active tab is distinguished by its panel-colored background (no top indicator). */ +ads--CDockWidgetTab[activeTab="true"] { + background: {{bg.panel}}; +} +ads--CDockWidgetTab QLabel { color: {{text.disabled}}; } +ads--CDockWidgetTab[activeTab="true"] QLabel { color: {{text.primary}}; } +ads--CDockWidgetTab:hover QLabel { color: {{text.secondary}}; } + +/* ---- splitters / resize handles ---- */ +/* Strong (black) gutters between panels. NOTE: the gutter THICKNESS is the + splitter handleWidth, which QSplitter ignores from QSS (both ::handle width + and qproperty-handleWidth) — so it's set in C++ (MainWindow, via setHandleWidth + on dockAreaCreated). This rule only colors the gutter. */ +/* Match ADS's own selector specificity (it uses the CDockContainerWidget + descendant form), else its palette(dark) grey wins over our black. */ +ads--CDockContainerWidget ads--CDockSplitter::handle { background: {{border.strong}}; } +ads--CDockContainerWidget ads--CDockSplitter::handle:hover { background: {{accent}}; } +ads--CResizeHandle { background: {{border.strong}}; } + +/* ---- title-bar & tab buttons ---- */ +ads--CTitleBarButton { + background: transparent; + border: none; + border-radius: {{radius.sm}}px; + padding: 2px; +} +ads--CTitleBarButton:hover { background: {{ctrl.hover}}; } +ads--CTitleBarButton:pressed { background: {{ctrl.pressed}}; } + +#tabCloseButton:hover { background: {{ctrl.hover}}; border: 1px solid {{border.subtle}}; } +#tabCloseButton:pressed { background: {{ctrl.pressed}}; } + +/* ---- floating docks ---- */ +ads--CFloatingWidgetTitleBar { background: {{bg.window}}; } +#floatingTitleCloseButton:hover, #floatingTitleMaximizeButton:hover { background: {{ctrl.hover}}; } + +/* ---- auto-hide side panels ---- */ +ads--CAutoHideSideBar { background: {{bg.elevated}}; } +ads--CAutoHideDockContainer { background: {{bg.panel}}; } +#autoHideTitleLabel { color: {{text.secondary}}; } + +/* ---- white icons ---- (override ADS's black default SVGs; see resources/icons/ads/, + generated white/gray copies. Static URLs, no token substitution.) */ +#tabCloseButton { + qproperty-icon: url(:/adsicons/close-button.svg), + url(:/adsicons/close-button-disabled.svg) disabled; +} +#dockAreaCloseButton { + qproperty-icon: url(:/adsicons/close-button.svg), + url(:/adsicons/close-button-disabled.svg) disabled; +} +#tabsMenuButton { qproperty-icon: url(:/adsicons/tabs-menu-button.svg); } +#detachGroupButton { + qproperty-icon: url(:/adsicons/detach-button.svg), + url(:/adsicons/detach-button-disabled.svg) disabled; +} +#floatingTitleCloseButton { qproperty-icon: url(:/adsicons/close-button.svg); } +#dockAreaMinimizeButton { qproperty-icon: url(:/adsicons/minimize-button-focused.svg); } +#dockAreaAutoHideButton { + qproperty-icon: url(:/adsicons/vs-pin-button.svg), + url(:/adsicons/vs-pin-button-disabled.svg) disabled; +} +ads--CAutoHideDockContainer #dockAreaAutoHideButton { + qproperty-icon: url(:/adsicons/vs-pin-button-pinned-focused.svg); +} +ads--CFloatingWidgetTitleBar { + qproperty-maximizeIcon: url(:/adsicons/maximize-button.svg); + qproperty-normalIcon: url(:/adsicons/restore-button.svg); +} diff --git a/resources/qss/app.qss.in b/resources/qss/app.qss.in new file mode 100644 index 00000000..eebe4d2f --- /dev/null +++ b/resources/qss/app.qss.in @@ -0,0 +1,515 @@ +/* + * TextureLab application stylesheet (template). + * + * Double-brace placeholders are substituted from the active theme JSON by + * QssBuilder at load time -- a placeholder naming the color token bg.panel + * becomes #353535, radius.sm becomes 3, font.ui.family becomes the UI font + * family, and so on. (This comment avoids writing a literal placeholder so the + * substituter has nothing to flag.) + * + * PHASE 1 -- app chrome: menus, toolbars, status bar, and the common controls + * that appear across every panel (buttons, inputs, combos, checks, sliders, + * scrollbars, tooltips, splitters). Panel-specific styling lands in later + * phases (see UI_DESIGN_SYSTEM_PRD.md section 6). + * + * Edit + save this file to see changes live (no rebuild): hot-reload is ON by + * default in Debug builds (also `--dev-theme` in any build; `--no-dev-theme` to + * disable). See the hot-reload section in UI_DESIGN_SYSTEM_PRD.md. + */ + +/* ============================================================= base ======= */ + +QWidget { + font-family: {{font.ui.family}}; + font-size: {{font.ui.size}}px; + color: {{text.primary}}; +} + +QToolTip { + background: {{bg.elevated}}; + color: {{text.primary}}; + border: 1px solid {{border.subtle}}; + padding: 3px 6px; +} + +/* ======================================================= menu bar ========= */ + +QMenuBar { + background: {{bg.panel}}; + border: none; + padding: 2px 4px; +} +QMenuBar::item { + background: transparent; + padding: 4px 10px; + border-radius: {{radius.sm}}px; +} +QMenuBar::item:selected { background: {{ctrl.hover}}; } +QMenuBar::item:pressed { background: {{selection}}; color: {{text.primary}}; } + +QMenu { + background: {{bg.elevated}}; + border: 1px solid {{border.subtle}}; + padding: 4px; +} +QMenu::item { + padding: 5px 24px 5px 22px; + border-radius: {{radius.sm}}px; +} +QMenu::item:selected { background: {{selection}}; color: {{text.primary}}; } +QMenu::item:disabled { color: {{text.disabled}}; } +QMenu::separator { + height: 1px; + background: {{border.subtle}}; + margin: 4px 8px; +} +QMenu::icon { padding-left: 6px; } + +/* ======================================================= tool bar ========= */ + +QToolBar { + background: {{bg.panel}}; + border: none; + border-bottom: 1px solid {{border.subtle}}; + padding: 3px; + spacing: {{space.sm}}px; +} +QToolBar::separator { + width: 1px; + background: {{border.subtle}}; + margin: 4px 4px; +} +/* Main window toolbar: strong (black) top border matching the ADS dock gutters. + Extra right padding so the Export button isn't flush against the window edge. */ +#MainToolbar { + border-top: 1px solid {{border.strong}}; + padding-right: 10px; +} +/* Hover/pressed on the main toolbar buttons (undo/redo/export): a lighter fill, + no colored outline. */ +#MainToolbar QToolButton:hover { background: {{ctrl.hover}}; } +#MainToolbar QToolButton:pressed { background: {{ctrl.pressed}}; } +QToolButton { + background: transparent; + border: 1px solid transparent; + border-radius: {{radius.sm}}px; + padding: 4px 6px; +} +QToolButton:hover { background: {{ctrl.hover}}; } +QToolButton:pressed { background: {{ctrl.pressed}}; } +QToolButton:checked { background: {{selection}}; border-color: {{accent.press}}; } +/* Disabled: the global QWidget color rule forces text.primary even when + disabled (QSS color doesn't auto-dim), so dim it back explicitly. */ +QToolButton:disabled { color: {{text.disabled}}; } +QToolButton::menu-indicator { image: none; } + +/* ====================================================== status bar ======== */ + +QStatusBar { + background: {{bg.panel}}; + border-top: 1px solid {{border.subtle}}; +} +QStatusBar::item { border: none; } +QStatusBar QLabel { background: transparent; } + +#StatusVersionLabel { + color: {{text.disabled}}; + padding: 0 6px; +} + +/* ========================================================= buttons ======== */ + +QPushButton { + background: {{ctrl.bg}}; + border: 1px solid {{border.input}}; + border-radius: {{radius.sm}}px; + padding: 4px 12px; + min-height: 18px; +} +QPushButton:hover { background: {{ctrl.hover}}; } +QPushButton:pressed { background: {{ctrl.pressed}}; } +QPushButton:disabled { color: {{text.disabled}}; border-color: {{border.subtle}}; } +QPushButton:default { border-color: {{accent}}; } + +QPushButton[variant="primary"] { + background: {{accent}}; + border: 1px solid {{accent.press}}; + color: {{text.primary}}; +} +QPushButton[variant="primary"]:hover { background: {{accent.hover}}; } +QPushButton[variant="primary"]:pressed { background: {{accent.press}}; } + +/* ==================================================== text inputs ========= */ + +QLineEdit, QPlainTextEdit, QTextEdit, QSpinBox, QDoubleSpinBox { + background: {{bg.input}}; + border: 1px solid {{border.input}}; + border-radius: {{radius.sm}}px; + padding: 3px 6px; + selection-background-color: {{selection}}; + selection-color: {{text.primary}}; +} +QLineEdit:focus, QPlainTextEdit:focus, QTextEdit:focus, +QSpinBox:focus, QDoubleSpinBox:focus { + border-color: {{accent}}; +} +QLineEdit:disabled, QSpinBox:disabled, QDoubleSpinBox:disabled { + color: {{text.disabled}}; +} + +QSpinBox::up-button, QDoubleSpinBox::up-button, +QSpinBox::down-button, QDoubleSpinBox::down-button { + width: 14px; + background: transparent; + border: none; +} +QSpinBox::up-button:hover, QDoubleSpinBox::up-button:hover, +QSpinBox::down-button:hover, QDoubleSpinBox::down-button:hover { + background: {{ctrl.hover}}; +} +/* Styling the up/down buttons drops the native arrows, so supply them. */ +QSpinBox::up-arrow, QDoubleSpinBox::up-arrow { + image: url(:/icons/chevron-up.svg); + width: 9px; + height: 9px; +} +QSpinBox::down-arrow, QDoubleSpinBox::down-arrow { + image: url(:/icons/chevron-down.svg); + width: 9px; + height: 9px; +} + +/* ==================================================== combo boxes ========= */ + +QComboBox { + background: {{ctrl.bg}}; + border: 1px solid {{border.input}}; + border-radius: {{radius.sm}}px; + padding: 3px 6px; + min-height: 18px; +} +QComboBox:hover { background: {{ctrl.hover}}; } +QComboBox:focus { border-color: {{accent}}; } +QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: center right; + width: 18px; + border: none; +} +/* Styling ::drop-down drops the native arrow, so supply our own chevron. */ +QComboBox::down-arrow { + image: url(:/icons/chevron-down.svg); + width: 12px; + height: 12px; +} +QComboBox QAbstractItemView { + background: {{bg.elevated}}; + border: 1px solid {{border.subtle}}; + selection-background-color: {{selection}}; + selection-color: {{text.primary}}; + outline: none; +} + +/* ============================================= checks & radios ============ */ + +QCheckBox, QRadioButton { spacing: 6px; background: transparent; } +QCheckBox::indicator, QRadioButton::indicator { + width: 15px; + height: 15px; + background: {{bg.input}}; + border: 1px solid {{border.input}}; +} +QCheckBox::indicator { border-radius: {{radius.sm}}px; } +QRadioButton::indicator { border-radius: 8px; } +QCheckBox::indicator:hover, QRadioButton::indicator:hover { border-color: {{accent}}; } +QCheckBox::indicator:checked, QRadioButton::indicator:checked { + background: {{ctrl.checked}}; + border-color: {{accent.press}}; +} + +/* ========================================================= sliders ======== */ + +QSlider::groove:horizontal { + height: 4px; + background: {{bg.input}}; + border-radius: 2px; +} +QSlider::sub-page:horizontal { background: {{accent}}; border-radius: 2px; } +QSlider::handle:horizontal { + background: {{text.secondary}}; + width: 12px; + height: 12px; + margin: -5px 0; + border-radius: 6px; +} +QSlider::handle:horizontal:hover { background: {{text.primary}}; } + +/* ======================================================= scrollbars ======= */ + +QScrollBar:vertical { + background: transparent; + width: 12px; + margin: 0; +} +QScrollBar:horizontal { + background: transparent; + height: 12px; + margin: 0; +} +QScrollBar::handle:vertical, QScrollBar::handle:horizontal { + background: {{gray.600}}; + border-radius: 4px; + border: 2px solid transparent; + background-clip: padding; +} +QScrollBar::handle:vertical { min-height: 28px; } +QScrollBar::handle:horizontal { min-width: 28px; } +QScrollBar::handle:hover { background: {{gray.550}}; } +QScrollBar::add-line, QScrollBar::sub-line { height: 0; width: 0; background: none; } +QScrollBar::add-page, QScrollBar::sub-page { background: none; } + +/* ========================================================= tab bar ======== */ + +QTabBar::tab { + background: {{bg.window}}; + color: {{text.secondary}}; + padding: 5px 12px; + border: none; +} +QTabBar::tab:selected { + background: {{bg.panel}}; + color: {{text.primary}}; + border-bottom: 2px solid {{accent}}; +} +QTabBar::tab:hover:!selected { color: {{text.primary}}; } + +/* ========================================================= splitter ======= */ + +QSplitter::handle { background: {{border.subtle}}; } +QSplitter::handle:horizontal { width: 1px; } +QSplitter::handle:vertical { height: 1px; } +QSplitter::handle:hover { background: {{accent}}; } + +/* ======================================================= progress ========= */ + +QProgressBar { + background: {{bg.input}}; + border: none; + border-radius: {{radius.sm}}px; + text-align: center; + color: {{text.primary}}; + max-height: 14px; +} +QProgressBar::chunk { background: {{accent}}; border-radius: {{radius.sm}}px; } + +/* ============================================= properties / inspector ===== */ + +/* Collapsible section header (AccordionWidget). + NOTE: min-height MUST be positive. With min-height:0 the flat header button + collapses to ~0px inside PropertiesWidget's dense QVBoxLayout, clipping the + title to invisibility (it survives in a looser layout, which masked it). The + original inline style set no min-height; keep an explicit one here since the + global QPushButton rule's 18px is what we're overriding. */ +#AccordionHeader { + background: {{gray.800}}; + color: {{text.secondary}}; + font-weight: bold; + text-align: left; + padding: 5px 8px; + border: none; + border-top: 1px solid {{border.subtle}}; + border-bottom: 1px solid {{border.subtle}}; + min-height: 20px; +} +#AccordionHeader:hover { background: {{gray.700}}; } + +/* "Frame" / "Comment" section titles in the properties panel */ +#PropSectionTitle { + font-weight: bold; + margin-bottom: 4px; +} + +/* Curve editor readout (In/Out values while dragging) */ +#CurveReadout { + color: {{text.disabled}}; + font-size: 10px; +} + +/* Image property placeholder / preview */ +#ImagePreview { + background: {{bg.input}}; + border: 1px solid {{border.input}}; + border-radius: {{radius.sm}}px; + color: {{text.disabled}}; +} + +/* Compact buttons (e.g. curve "Reset") */ +QPushButton[size="small"] { + font-size: 10px; + padding: 2px 6px; + min-height: 0; +} + +/* ================================================== viewport overlays ===== */ + +/* Compact 2D-view toolbar (save/copy/tile/recenter) */ +#View2DToolbar { padding: 1px; spacing: 1px; } +#View2DToolbar QToolButton { padding: 2px; } + +/* 2D view "Texture copied" toast (bottom-center, fades in/out) */ +#ViewToast { + background: {{bg.elevated}}; + color: {{text.primary}}; + border: 1px solid {{border.subtle}}; + padding: 10px 20px; + border-radius: {{radius.md}}px; +} + +/* ================================================= popups & dialogs ======= */ + +/* Floating node-search popup (Blender-style quick add). Frameless top-level, so + a crisp border reads as elevation; no radius (avoids frameless-corner artifacts). */ +#NodeSearchPopup { + background: {{bg.elevated}}; + border: 1px solid {{border.strong}}; +} + +/* Export dialog destination field + help text */ +#ExportDestination { + background: {{bg.input}}; + border: 1px solid {{border.input}}; + border-radius: {{radius.sm}}px; + padding: 5px; +} +#ExportDestination[empty="true"] { color: {{text.disabled}}; } +#ExportHelp { color: {{text.disabled}}; } + +/* Crash-report consent dialog. Reads as one block of prose, so the only + hierarchy is the title's size and the way the secondary text steps back. */ +#ConsentDialog { background: {{bg.window}}; } +#ConsentTitle { color: {{text.primary}}; font-size: 17px; font-weight: bold; } +#ConsentBody { color: {{text.secondary}}; font-size: 13px; } +#ConsentFactLead { color: {{text.primary}}; font-size: 12px; font-weight: bold; } +#ConsentFact { color: {{text.secondary}}; font-size: 12px; } +#ConsentNote { color: {{text.disabled}}; font-size: 11px; } +#ConsentSeparator { color: {{border.subtle}}; } + +/* About dialog typography */ +#AboutTitle { color: {{text.primary}}; font-size: 26px; font-weight: bold; } +#AboutTag { color: {{text.secondary}}; font-size: 12px; } +#AboutVersion { color: {{text.secondary}}; font-size: 13px; } +#AboutDesc { color: {{text.secondary}}; font-size: 13px; } +#AboutLink { font-size: 12px; } +#AboutCopyright { color: {{text.disabled}}; font-size: 11px; } +#AboutSeparator { color: {{border.subtle}}; } + +/* ==================================================== library panel ======= */ + +/* Library version indicator; turns warn-colored when the library is outdated */ +#LibraryVersionLabel { color: {{text.secondary}}; } +#LibraryVersionLabel[outdated="true"] { color: {{warn}}; } + +/* Library search: a bit larger/taller than the default input */ +#LibrarySearch { + font-size: 13px; + padding: 4px 6px; + min-height: 24px; +} + +/* Node-thumbnail grid: transparent card that gets an accent ring on hover/select */ +#LibraryList { + background: {{bg.base}}; + border: none; +} +#LibraryList::item { + border: 1px solid transparent; + border-radius: {{radius.sm}}px; + margin-left: 6px; + padding: 2px; +} +#LibraryList::item:hover { border: 1px solid {{accent}}; } +#LibraryList::item:selected { + border: 1px solid {{accent}}; + background: {{ctrl.hover}}; + color: {{text.primary}}; +} + +/* ==================================================== launcher =========== */ + +/* Project-manager window. Two thin bars with the card grid between them; the + cards themselves are painted by TextureCardDelegate, not styled here. */ + +#launcherTopBar, +#launcherActionBar { + background: {{bg.panel}}; +} +#launcherTopBar { border-bottom: 1px solid {{border.subtle}}; } +#launcherActionBar { border-top: 1px solid {{border.subtle}}; } + +#launcherGrid { + background: {{bg.elevated}}; + border: none; +} +/* The delegate owns the whole card, so the view must not draw its own + selection fill underneath it. */ +#launcherGrid::item, +#launcherGrid::item:hover, +#launcherGrid::item:selected { + background: transparent; + border: none; +} + +/* All / Recents / Starred, and the grid/list toggle — segmented controls that + fill when active rather than carrying an underline. Grey rather than accent + on purpose: the material thumbnails are meant to be the only saturated thing + in the window (see LAUNCHER_PRD.md §3.5). */ +#launcherFilterTab { + background: transparent; + border: none; + border-radius: {{radius.sm}}px; + color: {{text.secondary}}; + padding: 4px 10px; + font-size: 13px; +} +#launcherFilterTab:hover { + color: {{text.primary}}; + background: {{ctrl.bg}}; +} +#launcherFilterTab:checked { + color: {{text.primary}}; + background: {{ctrl.hover}}; +} + +/* First-run overlay: the message, plus a "New Texture" button on the genuine + empty state. It floats over the grid, so it must not paint a panel of its + own. */ +#launcherEmptyPanel { + background: transparent; +} +#launcherEmptyLabel { + background: transparent; + color: {{text.secondary}}; + font-size: 14px; +} + +/* Home button in the status bar — reopens the launcher */ +#StatusHomeButton { + background: transparent; + border: none; + color: {{text.secondary}}; + padding: 0px 6px; + font-size: 14px; +} +#StatusHomeButton:hover { color: {{text.primary}}; } + +/* Update notice — hidden until a newer release exists, so it only ever appears + when it has something to say. Accent-tinted rather than shouty. */ +#launcherUpdateButton { + background: {{accent}}; + border: none; + border-radius: {{radius.sm}}px; + color: {{white}}; + padding: 4px 10px; + font-size: 12px; +} +#launcherUpdateButton:hover { background: {{accent.hover}}; } diff --git a/resources/theme.qrc b/resources/theme.qrc new file mode 100644 index 00000000..d7f39548 --- /dev/null +++ b/resources/theme.qrc @@ -0,0 +1,22 @@ + + + themes/dark.json + + + qss/app.qss.in + qss/ads.qss.in + + + icons/ads/close-button.svg + icons/ads/close-button-disabled.svg + icons/ads/tabs-menu-button.svg + icons/ads/detach-button.svg + icons/ads/detach-button-disabled.svg + icons/ads/minimize-button-focused.svg + icons/ads/maximize-button.svg + icons/ads/restore-button.svg + icons/ads/vs-pin-button.svg + icons/ads/vs-pin-button-disabled.svg + icons/ads/vs-pin-button-pinned-focused.svg + + diff --git a/resources/themes/dark.json b/resources/themes/dark.json new file mode 100644 index 00000000..65d0642d --- /dev/null +++ b/resources/themes/dark.json @@ -0,0 +1,139 @@ +{ + "meta": { + "name": "TextureLab Dark", + "base": "dark" + }, + "color": { + "gray.900": "#191919", + "gray.850": "#232323", + "gray.800": "#2B2B2B", + "gray.750": "#2E2E2E", + "gray.700": "#353535", + "gray.650": "#404040", + "gray.600": "#505050", + "gray.550": "#5C5C5C", + "gray.400": "#7F7F7F", + "gray.300": "#787878", + "gray.200": "#C8C8C8", + "white": "#FFFFFF", + "black": "#000000", + "accent": "#2A82DA", + "accent.hover": "#3D93E8", + "accent.press": "#2069B8", + "warn": "#E5A54B", + "danger": "#E5484D", + "ok": "#3DAF6E", + "bg.window": "@gray.700", + "bg.panel": "@gray.700", + "bg.base": "@gray.850", + "bg.elevated": "@gray.900", + "bg.input": "@gray.750", + "border.subtle": "@gray.900", + "border.strong": "@black", + "border.input": "@gray.600", + "text.primary": "@white", + "text.secondary": "@gray.200", + "text.disabled": "@gray.400", + "selection": "@accent", + "ctrl.bg": "@gray.650", + "ctrl.hover": "@gray.600", + "ctrl.pressed": "@gray.550", + "ctrl.checked": "@accent", + "node.bg": "#0A0A0A", + "node.border": "@black", + "node.border.hover": "@gray.300", + "node.border.select": "@gray.200", + "node.title": "@white", + "node.channel": "#C8FFC8", + "socket.fill": "#AAAAAA", + "wire": "#AAAAAA", + "wire.dragging": "#969696", + "wire.selected": "@accent", + "grid.bg": "@gray.700", + "grid.fine": "#3C3C3C", + "grid.coarse": "@gray.900", + "checker.a": "#C0C0C0", + "checker.b": "#808080", + "frame.select": "@warn", + "comment.fill": "@white", + "comment.text": "#F0F0F0", + "view2d.bg": "#212121", + "view3d.clear": "#1A1A1A", + "view3d.grid": "@gray.600", + "curve.bg": "@bg.elevated", + "curve.grid": "@gray.850", + "curve.identity": "@gray.800", + "curve.line": "@text.secondary", + "curve.anchor": "@text.disabled", + "curve.anchor.hover": "@text.primary", + "curve.anchor.select": "@accent", + "curve.handle.line": "@gray.600", + "curve.handle.dot": "@text.disabled", + "curve.handle.hover": "@text.secondary", + "curve.handle.corner": "@warn", + "launcher.card": "@gray.800", + "launcher.card.hover": "@gray.700", + "launcher.card.border": "@gray.900", + "launcher.thumb.bg": "@gray.850", + "launcher.star": "@warn", + "launcher.badge": "@warn", + "launcher.pip.off": "@gray.650", + "launcher.pip.on": "@gray.200", + "launcher.open.dot": "@ok" + }, + "palette": { + "window": "@bg.window", + "windowText": "@text.primary", + "base": "@bg.base", + "alternateBase": "@bg.window", + "toolTipBase": "@gray.900", + "toolTipText": "@text.primary", + "text": "@text.primary", + "button": "@bg.window", + "buttonText": "@text.primary", + "brightText": "@danger", + "link": "@accent", + "highlight": "@accent", + "highlightedText": "@black", + "disabled.windowText": "@text.disabled", + "disabled.text": "@text.disabled", + "disabled.buttonText": "@text.disabled", + "disabled.highlightedText": "@text.disabled", + "disabled.highlight": "@gray.600" + }, + "radius": { + "sm": 3, + "md": 5, + "lg": 8, + "pill": 999 + }, + "space": { + "xs": 2, + "sm": 4, + "md": 8, + "lg": 12, + "xl": 16 + }, + "motion": { + "fast": 120, + "base": 180, + "slow": 260 + }, + "font": { + "ui": { + "family": "Segoe UI, Inter, sans-serif", + "size": 12, + "weight": 400 + }, + "mono": { + "family": "Consolas, JetBrains Mono, monospace", + "size": 12, + "weight": 400 + }, + "title": { + "family": "Segoe UI, Inter, sans-serif", + "size": 13, + "weight": 600 + } + } +} diff --git a/scripts/check-theme-hygiene.sh b/scripts/check-theme-hygiene.sh new file mode 100755 index 00000000..395ab681 --- /dev/null +++ b/scripts/check-theme-hygiene.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# Theme hygiene gate. +# +# The theme system (src/theme/ + resources/themes/*.json + resources/qss/*.qss.in) +# is the single source of truth for the app's look. This check fails the build if +# new *inline widget stylesheets* or *hardcoded QColor literals* creep into the +# UI / rendering code, which would bypass the theme and break --dev-theme +# hot-reload. +# +# Legitimate exceptions carry a "// theme-exempt: " marker on the same +# line (e.g. dynamic color-DATA swatches, or the one call that applies the +# composed theme sheet to the ADS dock manager). See UI_DESIGN_SYSTEM_PRD.md. +# +# Out of scope by design: src/theme (the system), src/ads (vendored submodule), +# src/colorpicker (a color-DATA widget lib), src/texturelab/libraries (node +# default *values*, not UI styling). + +set -uo pipefail +cd "$(dirname "$0")/.." + +# UI + rendering code that must stay theme-driven. +SCOPE=( + "src/texturelab/widgets" + "src/texturelab/mainwindow.cpp" + "src/nodegraph/graph" +) + +status=0 + +# Drop matches that live on a commented-out line (content after "file:line:" +# starts with //) and any line carrying the theme-exempt marker. +drop_noise() { grep -vE ':[0-9]+:[[:space:]]*//' | grep -v 'theme-exempt'; } + +# 1) Inline widget stylesheets -> belong in resources/qss/app.qss.in. +hits=$(grep -rnE '(->|\.)setStyleSheet\(' "${SCOPE[@]}" --include=*.cpp 2>/dev/null \ + | drop_noise) +if [ -n "$hits" ]; then + echo "FAIL: inline setStyleSheet() in UI code." + echo " Move the rule to resources/qss/app.qss.in and target it by objectName," + echo " or add '// theme-exempt: ' if it is genuinely dynamic data." + echo "$hits" | sed 's/^/ /' + echo + status=1 +fi + +# 2) Hardcoded numeric QColor literals in paint code -> use a token. +hits=$(grep -rnE 'QColor\((0x)?[0-9]' "${SCOPE[@]}" --include=*.cpp 2>/dev/null \ + | drop_noise) +if [ -n "$hits" ]; then + echo "FAIL: hardcoded QColor(...) literal in rendering code." + echo " Add a token to resources/themes/dark.json + src/theme/tokens.h and read it" + echo " via ntColor()/ThemeManager::instance().theme().color(), or mark // theme-exempt." + echo "$hits" | sed 's/^/ /' + echo + status=1 +fi + +if [ "$status" -eq 0 ]; then + echo "theme hygiene: OK — no un-exempted inline stylesheets or hardcoded colors in UI code." +fi +exit $status diff --git a/scripts/sentry-local-test.sh b/scripts/sentry-local-test.sh new file mode 100755 index 00000000..47239b14 --- /dev/null +++ b/scripts/sentry-local-test.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Local end-to-end test for Sentry crash symbolication (Linux). +# +# Mirrors what CI does, then triggers a real crash so you can confirm the +# stack trace is readable in sentry.io. +# +# Requires: sentry-cli on PATH, and these env vars: +# SENTRY_AUTH_TOKEN – an auth token with project:write / project:releases +# SENTRY_ORG – your Sentry org slug +# SENTRY_PROJECT – your Sentry project slug +# +# Usage: +# SENTRY_AUTH_TOKEN=xxx SENTRY_ORG=xxx SENTRY_PROJECT=xxx \ +# ./scripts/sentry-local-test.sh [build-dir] +set -euo pipefail + +BUILD_DIR="${1:-build-sentry-test}" +BIN="$BUILD_DIR/src/texturelab/texturelab" + +: "${SENTRY_AUTH_TOKEN:?set SENTRY_AUTH_TOKEN}" +: "${SENTRY_ORG:?set SENTRY_ORG}" +: "${SENTRY_PROJECT:?set SENTRY_PROJECT}" + +if [ ! -f "$BIN" ]; then + echo "!! $BIN not found. Build first:" + echo " cmake --build $BUILD_DIR --target texturelab --parallel \$(nproc)" + exit 1 +fi + +echo "== 1) Inspecting DIF of the freshly built binary ==" +sentry-cli debug-files check "$BIN" + +echo +echo "== 2) Uploading FULL unstripped binary (debug + unwind + sources) ==" +sentry-cli debug-files upload --include-sources "$BIN" + +echo +echo "== 3) Stripping the shipped copy (build-id / Debug ID is preserved) ==" +strip "$BIN" +sentry-cli debug-files check "$BIN" # should still show a matching Debug ID + +echo +echo "== 4) Triggering a deliberate crash so Crashpad uploads a minidump ==" +# Wipe any stale crash DB so we know the minidump is from this run. +rm -rf "$HOME/.local/share/texturelab/texturelab/sentry" 2>/dev/null || true +set +e +"$BIN" --sentry-crash-test +echo " app exited with code $? (a crash is expected)" +set -e + +echo +echo "== Done ==" +echo "Crashpad uploads the minidump in the background. Open your Sentry project:" +echo " https://$SENTRY_ORG.sentry.io/issues/" +echo "You should see a new crash whose stack trace includes 'sentryCrashTest'" +echo "and 'main' with file/line info. If the frames are symbolicated, the fix works." diff --git a/src/catalog/CMakeLists.txt b/src/catalog/CMakeLists.txt new file mode 100644 index 00000000..d9a9a9e2 --- /dev/null +++ b/src/catalog/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.10) + +# The launcher's data layer: index.db (what textures exist) and thumbs.db +# (their cached previews). See LAUNCHER_PRD.md. +# +# Deliberately free of any dependency on the app's node graph or on Qt Widgets, +# so it builds and tests headless. The mapping between TextureChannel and this +# library's ChannelBit lives at the app boundary, not here. + +project(catalog LANGUAGES CXX) + +set(CMAKE_AUTOMOC ON) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Sets QT_VERSION_MAJOR, matching how the other subdirectories resolve Qt. +find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Core) +find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Sql) + +add_library(catalog STATIC + database.h + database.cpp + catalogindex.h + catalogindex.cpp + thumbnailcache.h + thumbnailcache.cpp + texturerecord.h +) + +target_include_directories(catalog PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) + +# Qt6::Sql pulls in the bundled QSQLITE driver plugin. The deploy tooling +# (linuxdeploy --plugin qt, windeployqt, macdeployqt) copies sqldrivers/ +# automatically for any target that links it. +target_link_libraries(catalog PUBLIC + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Sql +) + +set_target_properties(catalog PROPERTIES POSITION_INDEPENDENT_CODE ON) diff --git a/src/catalog/catalogindex.cpp b/src/catalog/catalogindex.cpp new file mode 100644 index 00000000..dab6f8b0 --- /dev/null +++ b/src/catalog/catalogindex.cpp @@ -0,0 +1,662 @@ +#include "catalogindex.h" + +#include +#include +#include +#include + +namespace catalog { + +namespace { + +// Column order shared by every SELECT, so readRow() can stay a single function. +const char* const kColumns = + "id, path, name, file_size, file_mtime, width, height, " + "node_count, lib_version, channels, created_at, last_opened, last_saved, " + "starred, missing_since"; + +// Escapes the LIKE metacharacters so a search for "50%" doesn't match +// everything. Paired with ESCAPE '\' in the query. +QString escapeLike(const QString& term) +{ + QString out = term; + out.replace(QLatin1Char('\\'), QLatin1String("\\\\")); + out.replace(QLatin1Char('%'), QLatin1String("\\%")); + out.replace(QLatin1Char('_'), QLatin1String("\\_")); + return out; +} + +QVariant nullIfZero(qint64 value) +{ + return value == 0 ? QVariant() : QVariant(value); +} + +} // namespace + +CatalogIndex::CatalogIndex() = default; + +CatalogIndex::~CatalogIndex() +{ + close(); +} + +bool CatalogIndex::open(const QString& path) +{ + close(); + + // index.db holds no blobs and is read far more than written, so the + // defaults are right; only thumbs.db needs page_size/auto_vacuum tuning. + Database::Options options; + options.walMode = true; + options.foreignKeys = true; + + if (!db.open(path, options)) + return false; + + const bool fresh = db.scalar( + QStringLiteral("SELECT count(*) FROM sqlite_master WHERE type='table'")) + == 0; + + if (fresh) { + if (!createSchema()) { + close(); + return false; + } + currentVersion = SchemaVersion; + return true; + } + + currentVersion = readSchemaVersion(); + + if (currentVersion > SchemaVersion) { + // Written by a newer build. Migrating backwards is guesswork and this + // file cannot be regenerated by rescanning, so reopen read-only and let + // the launcher show what it can. + qWarning("catalog: index schema v%d is newer than this build (v%d); opening read-only", + currentVersion, SchemaVersion); + db.close(); + + Database::Options ro = options; + ro.readOnly = true; + if (!db.open(path, ro)) + return false; + + readOnly = true; + return true; + } + + if (currentVersion < SchemaVersion && !migrate(currentVersion)) { + close(); + return false; + } + + return true; +} + +void CatalogIndex::close() +{ + db.close(); + readOnly = false; + currentVersion = 0; +} + +bool CatalogIndex::createSchema() +{ + QStringList statements; + + statements << QStringLiteral(R"( + CREATE TABLE texture ( + id INTEGER PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + file_size INTEGER NOT NULL DEFAULT 0, + file_mtime INTEGER NOT NULL DEFAULT 0, + width INTEGER, + height INTEGER, + node_count INTEGER, + lib_version TEXT, + channels INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + last_opened INTEGER, + last_saved INTEGER, + starred INTEGER NOT NULL DEFAULT 0, + missing_since INTEGER + ) + )"); + + statements << QStringLiteral(R"( + CREATE TABLE tag ( + texture_id INTEGER NOT NULL REFERENCES texture(id) ON DELETE CASCADE, + tag TEXT NOT NULL, + PRIMARY KEY (texture_id, tag) + ) WITHOUT ROWID + )"); + + statements << QStringLiteral( + "CREATE INDEX ix_recent ON texture(last_opened DESC) WHERE missing_since IS NULL"); + statements << QStringLiteral("CREATE INDEX ix_name ON texture(name)"); + + statements << QStringLiteral("CREATE TABLE meta (k TEXT PRIMARY KEY, v TEXT)"); + statements << QStringLiteral("INSERT INTO meta (k, v) VALUES ('schema_version', '%1')") + .arg(SchemaVersion); + + return db.execBatch(statements); +} + +int CatalogIndex::readSchemaVersion() +{ + QSqlQuery query = db.prepare(QStringLiteral("SELECT v FROM meta WHERE k = 'schema_version'")); + if (!query.exec() || !query.next()) + return 0; + + return query.value(0).toInt(); +} + +bool CatalogIndex::writeSchemaVersion(int version) +{ + QSqlQuery query = db.prepare(QStringLiteral( + "INSERT INTO meta (k, v) VALUES ('schema_version', ?) " + "ON CONFLICT(k) DO UPDATE SET v = excluded.v")); + query.addBindValue(QString::number(version)); + if (!query.exec()) { + db.setError(query.lastError().text()); + return false; + } + currentVersion = version; + return true; +} + +bool CatalogIndex::migrate(int fromVersion) +{ + // No migrations yet — v1 is the first shipped schema. When one is needed, + // add a step here per version, each inside its own transaction, and bump + // SchemaVersion. Steps must be additive: this file is the only copy of the + // user's history. + Q_UNUSED(fromVersion); + return writeSchemaVersion(SchemaVersion); +} + +// --- write points --------------------------------------------------------- + +bool CatalogIndex::recordOpened(TextureRecord& rec, qint64 whenMs) +{ + return upsert(rec, Stamp::Opened, whenMs); +} + +bool CatalogIndex::recordSaved(TextureRecord& rec, qint64 whenMs) +{ + return upsert(rec, Stamp::Saved, whenMs); +} + +bool CatalogIndex::upsert(TextureRecord& rec, Stamp stamp, qint64 whenMs) +{ + if (readOnly) { + db.setError(QStringLiteral("index is read-only")); + return false; + } + if (rec.path.isEmpty()) { + db.setError(QStringLiteral("cannot record a texture with no path")); + return false; + } + + if (rec.name.isEmpty()) + rec.name = QFileInfo(rec.path).completeBaseName(); + + if (stamp == Stamp::Opened) + rec.lastOpened = whenMs; + else + rec.lastSaved = whenMs; + + // created_at is preserved on conflict; every other column reflects what we + // just learned from the document. starred and missing_since are handled + // separately: starring is a user action this call knows nothing about, and + // touching a file necessarily means it isn't missing. + QSqlQuery query = db.prepare(QStringLiteral(R"( + INSERT INTO texture (path, name, file_size, file_mtime, + width, height, node_count, lib_version, channels, + created_at, last_opened, last_saved, starred, missing_since) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL) + ON CONFLICT(path) DO UPDATE SET + name = excluded.name, + file_size = excluded.file_size, + file_mtime = excluded.file_mtime, + width = excluded.width, + height = excluded.height, + node_count = excluded.node_count, + lib_version = excluded.lib_version, + channels = excluded.channels, + last_opened = max(coalesce(texture.last_opened, 0), + coalesce(excluded.last_opened, 0)), + last_saved = max(coalesce(texture.last_saved, 0), + coalesce(excluded.last_saved, 0)), + missing_since = NULL + )")); + + query.addBindValue(rec.path); + query.addBindValue(rec.name); + query.addBindValue(rec.fileSize); + query.addBindValue(rec.fileMtime); + query.addBindValue(rec.width); + query.addBindValue(rec.height); + query.addBindValue(rec.nodeCount); + query.addBindValue(rec.libVersion.isEmpty() ? QVariant() : QVariant(rec.libVersion)); + query.addBindValue(rec.channels); + query.addBindValue(rec.createdAt != 0 ? rec.createdAt : whenMs); + query.addBindValue(nullIfZero(rec.lastOpened)); + query.addBindValue(nullIfZero(rec.lastSaved)); + + if (!query.exec()) { + db.setError(query.lastError().text()); + qWarning("catalog: upsert failed for %s: %s", qPrintable(rec.path), + qPrintable(db.lastError())); + return false; + } + + // max() above can leave the in-memory record behind the stored row, and the + // id is unknown on the update path, so read back rather than guess. + const TextureRecord stored = byPath(rec.path); + if (!stored.isValid()) { + db.setError(QStringLiteral("row vanished immediately after upsert")); + return false; + } + rec = stored; + return true; +} + +bool CatalogIndex::setStarred(qint64 id, bool starred) +{ + if (readOnly) { + db.setError(QStringLiteral("index is read-only")); + return false; + } + + QSqlQuery query = db.prepare(QStringLiteral("UPDATE texture SET starred = ? WHERE id = ?")); + query.addBindValue(starred ? 1 : 0); + query.addBindValue(id); + if (!query.exec()) { + db.setError(query.lastError().text()); + return false; + } + return query.numRowsAffected() > 0; +} + +bool CatalogIndex::remove(qint64 id) +{ + if (readOnly) { + db.setError(QStringLiteral("index is read-only")); + return false; + } + + QSqlQuery query = db.prepare(QStringLiteral("DELETE FROM texture WHERE id = ?")); + query.addBindValue(id); + if (!query.exec()) { + db.setError(query.lastError().text()); + return false; + } + return query.numRowsAffected() > 0; +} + +int CatalogIndex::removeAllMissing() +{ + if (readOnly) { + db.setError(QStringLiteral("index is read-only")); + return 0; + } + + QSqlQuery query = db.prepare( + QStringLiteral("DELETE FROM texture WHERE missing_since IS NOT NULL")); + if (!query.exec()) { + db.setError(query.lastError().text()); + return 0; + } + return query.numRowsAffected(); +} + +bool CatalogIndex::clearRecents() +{ + if (readOnly) { + db.setError(QStringLiteral("index is read-only")); + return false; + } + + return db.exec(QStringLiteral("UPDATE texture SET last_opened = NULL")); +} + +// --- reconciliation ------------------------------------------------------- + +QVector CatalogIndex::allRecords() const +{ + QVector records; + + QSqlQuery query = const_cast(db).prepare( + QStringLiteral("SELECT %1 FROM texture").arg(QLatin1String(kColumns))); + if (!query.exec()) + return records; + + while (query.next()) + records.append(readRow(query)); + + return records; +} + +bool CatalogIndex::markMissing(qint64 id, qint64 whenMs) +{ + if (readOnly) { + db.setError(QStringLiteral("index is read-only")); + return false; + } + + // Only stamp the first time, so the dimmed card can say how long it's been + // gone rather than resetting on every launcher open. + QSqlQuery query = db.prepare(QStringLiteral( + "UPDATE texture SET missing_since = ? WHERE id = ? AND missing_since IS NULL")); + query.addBindValue(whenMs); + query.addBindValue(id); + if (!query.exec()) { + db.setError(query.lastError().text()); + return false; + } + return true; +} + +bool CatalogIndex::markPresent(qint64 id, qint64 fileSize, qint64 fileMtime) +{ + if (readOnly) { + db.setError(QStringLiteral("index is read-only")); + return false; + } + + QSqlQuery query = db.prepare(QStringLiteral( + "UPDATE texture SET missing_since = NULL, file_size = ?, file_mtime = ? WHERE id = ?")); + query.addBindValue(fileSize); + query.addBindValue(fileMtime); + query.addBindValue(id); + if (!query.exec()) { + db.setError(query.lastError().text()); + return false; + } + return true; +} + +qint64 CatalogIndex::relocate(qint64 id, const QString& newPath, qint64 fileSize, + qint64 fileMtime) +{ + if (readOnly) { + db.setError(QStringLiteral("index is read-only")); + return -1; + } + if (newPath.isEmpty()) + return -1; + + const TextureRecord moving = byId(id); + if (!moving.isValid()) + return -1; + + Transaction tx(db); + if (!tx.isActive()) + return -1; + + const TextureRecord existing = byPath(newPath); + + // Already indexed under the new path — usually because the user opened it + // there before getting round to fixing the stale card. Fold the old row's + // user data into it rather than failing on the UNIQUE constraint. + if (existing.isValid() && existing.id != id) { + QSqlQuery merge = db.prepare(QStringLiteral( + "UPDATE texture SET " + " starred = max(starred, ?), " + " created_at = min(created_at, ?), " + " last_opened = max(coalesce(last_opened, 0), ?), " + " last_saved = max(coalesce(last_saved, 0), ?), " + " missing_since = NULL " + "WHERE id = ?")); + merge.addBindValue(moving.starred ? 1 : 0); + merge.addBindValue(moving.createdAt != 0 ? moving.createdAt : existing.createdAt); + merge.addBindValue(moving.lastOpened); + merge.addBindValue(moving.lastSaved); + merge.addBindValue(existing.id); + if (!merge.exec()) { + db.setError(merge.lastError().text()); + return -1; + } + + QSqlQuery moveTags = db.prepare( + QStringLiteral("INSERT OR IGNORE INTO tag (texture_id, tag) " + "SELECT ?, tag FROM tag WHERE texture_id = ?")); + moveTags.addBindValue(existing.id); + moveTags.addBindValue(id); + if (!moveTags.exec()) { + db.setError(moveTags.lastError().text()); + return -1; + } + + QSqlQuery drop = db.prepare(QStringLiteral("DELETE FROM texture WHERE id = ?")); + drop.addBindValue(id); + if (!drop.exec()) { + db.setError(drop.lastError().text()); + return -1; + } + + return tx.commit() ? existing.id : -1; + } + + QSqlQuery query = db.prepare(QStringLiteral( + "UPDATE texture SET path = ?, name = ?, file_size = ?, file_mtime = ?, " + "missing_since = NULL WHERE id = ?")); + query.addBindValue(newPath); + query.addBindValue(QFileInfo(newPath).completeBaseName()); + query.addBindValue(fileSize); + query.addBindValue(fileMtime); + query.addBindValue(id); + + if (!query.exec()) { + db.setError(query.lastError().text()); + return -1; + } + + return tx.commit() ? id : -1; +} + +// --- reads ---------------------------------------------------------------- + +QString CatalogIndex::whereClause(const Query& query) +{ + QStringList clauses; + + switch (query.filter) { + case Filter::All: + break; + case Filter::Recents: + // Matches the ix_recent partial index. Recents is "jump back in", so a + // file that isn't there isn't useful; All and Starred still show it. + clauses << QStringLiteral("last_opened IS NOT NULL") + << QStringLiteral("missing_since IS NULL"); + break; + case Filter::Starred: + clauses << QStringLiteral("starred = 1"); + break; + } + + if (!query.search.isEmpty()) { + clauses << QStringLiteral( + R"((name LIKE :search ESCAPE '\' OR EXISTS ( + SELECT 1 FROM tag WHERE tag.texture_id = texture.id + AND tag.tag LIKE :search ESCAPE '\')))"); + } + + if (clauses.isEmpty()) + return QString(); + + return QStringLiteral(" WHERE ") + clauses.join(QStringLiteral(" AND ")); +} + +QString CatalogIndex::orderByClause(const Query& query) +{ + const QString direction = query.ascending ? QStringLiteral("ASC") : QStringLiteral("DESC"); + + switch (query.sort) { + case SortKey::Name: + return QStringLiteral(" ORDER BY name COLLATE NOCASE %1, id %1").arg(direction); + case SortKey::Size: + return QStringLiteral(" ORDER BY file_size %1, id %1").arg(direction); + case SortKey::Opened: + // Spelled out rather than using NULLS LAST so the ordering doesn't + // depend on which SQLite version Qt happens to bundle. Never-opened + // rows sort last either way. + return QStringLiteral(" ORDER BY (last_opened IS NULL) ASC, last_opened %1, id %1") + .arg(direction); + case SortKey::Modified: + break; + } + return QStringLiteral(" ORDER BY file_mtime %1, id %1").arg(direction); +} + +QVector CatalogIndex::list(const Query& query) const +{ + QVector records; + + QString sql = QStringLiteral("SELECT %1 FROM texture").arg(QLatin1String(kColumns)); + sql += whereClause(query); + sql += orderByClause(query); + + if (query.limit >= 0) + sql += QStringLiteral(" LIMIT :limit OFFSET :offset"); + + QSqlQuery q = const_cast(db).prepare(sql); + if (!query.search.isEmpty()) + q.bindValue(QStringLiteral(":search"), + QStringLiteral("%%%1%%").arg(escapeLike(query.search))); + if (query.limit >= 0) { + q.bindValue(QStringLiteral(":limit"), query.limit); + q.bindValue(QStringLiteral(":offset"), query.offset); + } + + if (!q.exec()) { + const_cast(db).setError(q.lastError().text()); + qWarning("catalog: list failed: %s", qPrintable(q.lastError().text())); + return records; + } + + while (q.next()) + records.append(readRow(q)); + + return records; +} + +int CatalogIndex::count(const Query& query) const +{ + QString sql = QStringLiteral("SELECT count(*) FROM texture"); + sql += whereClause(query); + + QSqlQuery q = const_cast(db).prepare(sql); + if (!query.search.isEmpty()) + q.bindValue(QStringLiteral(":search"), + QStringLiteral("%%%1%%").arg(escapeLike(query.search))); + + if (!q.exec() || !q.next()) + return 0; + + return q.value(0).toInt(); +} + +TextureRecord CatalogIndex::readRow(const QSqlQuery& query) +{ + TextureRecord rec; + rec.id = query.value(0).toLongLong(); + rec.path = query.value(1).toString(); + rec.name = query.value(2).toString(); + rec.fileSize = query.value(3).toLongLong(); + rec.fileMtime = query.value(4).toLongLong(); + rec.width = query.value(5).toInt(); + rec.height = query.value(6).toInt(); + rec.nodeCount = query.value(7).toInt(); + rec.libVersion = query.value(8).toString(); + rec.channels = query.value(9).toInt(); + rec.createdAt = query.value(10).toLongLong(); + rec.lastOpened = query.value(11).isNull() ? 0 : query.value(11).toLongLong(); + rec.lastSaved = query.value(12).isNull() ? 0 : query.value(12).toLongLong(); + rec.starred = query.value(13).toBool(); + rec.missingSince = query.value(14).isNull() ? 0 : query.value(14).toLongLong(); + return rec; +} + +TextureRecord CatalogIndex::byId(qint64 id) const +{ + QSqlQuery query = const_cast(db).prepare( + QStringLiteral("SELECT %1 FROM texture WHERE id = ?").arg(QLatin1String(kColumns))); + query.addBindValue(id); + + if (!query.exec() || !query.next()) + return TextureRecord(); + + return readRow(query); +} + +TextureRecord CatalogIndex::byPath(const QString& path) const +{ + QSqlQuery query = const_cast(db).prepare( + QStringLiteral("SELECT %1 FROM texture WHERE path = ?").arg(QLatin1String(kColumns))); + query.addBindValue(path); + + if (!query.exec() || !query.next()) + return TextureRecord(); + + return readRow(query); +} + +// --- tags ----------------------------------------------------------------- + +QStringList CatalogIndex::tags(qint64 id) const +{ + QStringList result; + + QSqlQuery query = const_cast(db).prepare( + QStringLiteral("SELECT tag FROM tag WHERE texture_id = ? ORDER BY tag")); + query.addBindValue(id); + + if (!query.exec()) + return result; + + while (query.next()) + result << query.value(0).toString(); + + return result; +} + +bool CatalogIndex::addTag(qint64 id, const QString& tag) +{ + if (readOnly || tag.isEmpty()) { + db.setError(QStringLiteral("index is read-only or tag is empty")); + return false; + } + + QSqlQuery query = db.prepare( + QStringLiteral("INSERT OR IGNORE INTO tag (texture_id, tag) VALUES (?, ?)")); + query.addBindValue(id); + query.addBindValue(tag); + if (!query.exec()) { + db.setError(query.lastError().text()); + return false; + } + return true; +} + +bool CatalogIndex::removeTag(qint64 id, const QString& tag) +{ + if (readOnly) { + db.setError(QStringLiteral("index is read-only")); + return false; + } + + QSqlQuery query = db.prepare( + QStringLiteral("DELETE FROM tag WHERE texture_id = ? AND tag = ?")); + query.addBindValue(id); + query.addBindValue(tag); + if (!query.exec()) { + db.setError(query.lastError().text()); + return false; + } + return query.numRowsAffected() > 0; +} + +} // namespace catalog diff --git a/src/catalog/catalogindex.h b/src/catalog/catalogindex.h new file mode 100644 index 00000000..8c343e0d --- /dev/null +++ b/src/catalog/catalogindex.h @@ -0,0 +1,131 @@ +#pragma once + +#include "database.h" +#include "texturerecord.h" + +#include +#include + +namespace catalog { + +// Repository over index.db — the durable record of every texture the app has +// created, opened, or saved. +// +// There is no filesystem scanner. Rows appear here only because the user did +// something, which means this file is the *only* record of what the launcher +// knows; losing it loses history even though every .texture file is still on +// disk. Two consequences are baked into this class: nothing is ever deleted +// implicitly (missing files are flagged, not removed), and a database written +// by a newer build is opened read-only rather than migrated speculatively. +class CatalogIndex { +public: + // Bump when the schema changes, and add a step to migrate(). + static constexpr int SchemaVersion = 1; + + CatalogIndex(); + ~CatalogIndex(); + + CatalogIndex(const CatalogIndex&) = delete; + CatalogIndex& operator=(const CatalogIndex&) = delete; + + // Creates the schema if the file is new, migrates it if it's older, and + // falls back to read-only if it's newer than this build understands. + bool open(const QString& path); + void close(); + + bool isOpen() const { return db.isOpen(); } + + // True when the file was written by a newer build. Every mutating call + // fails in this state; the launcher should still show what it can. + bool isReadOnly() const { return readOnly; } + + int schemaVersion() const { return currentVersion; } + QString lastError() const { return db.lastError(); } + Database& database() { return db; } + + // --- write points (see LAUNCHER_PRD.md §6.1) ------------------------- + + // Upserts by path and stamps last_opened. On success `rec.id` is filled in. + // + // Paths are identity here. A texture moved on disk becomes a new row at its + // new path, and the old one stays behind, flagged missing until the user + // removes it — no attempt is made to recognize the two as the same file. + // Detecting that needs a content hash or an mtime heuristic, and neither + // earns its keep for how often textures actually move. + bool recordOpened(TextureRecord& rec, qint64 whenMs); + + // Upserts by path and stamps last_saved. + bool recordSaved(TextureRecord& rec, qint64 whenMs); + + bool setStarred(qint64 id, bool starred); + + // Forgets a texture. Never touches the file on disk. + bool remove(qint64 id); + int removeAllMissing(); + + // Clears last_opened everywhere, emptying the Recents view. Keeps the rows, + // their stars, and their tags — "clear recents" is about history, not about + // discarding what the user has collected. + bool clearRecents(); + + // --- reconciliation (see LAUNCHER_PRD.md §6.2) ----------------------- + + // Every row, cheapest form, for the stat() pass on launcher open. + QVector allRecords() const; + + bool markMissing(qint64 id, qint64 whenMs); + + // Records that a file is present with this size/mtime, clearing any missing + // flag. Metadata is refreshed on next open, not here — this pass must not + // parse files. + // + // A caller that sees size or mtime differ from the stored row knows the + // file was edited outside the app, and should drop that texture's cached + // thumbnail before calling this. That comparison is the only change + // detection in the system. + bool markPresent(qint64 id, qint64 fileSize, qint64 fileMtime); + + // Re-points a row at a new path — the user found a file that had gone + // missing. This is the deliberate counterpart to not detecting moves + // automatically (§6.2): the launcher can't guess, but the user can tell it. + // + // If another row already occupies `newPath`, the two are merged: the + // survivor keeps that path and inherits stars, tags, and the earlier + // created_at, and the relocated row is deleted. Returns the surviving row + // id, or -1 on failure. + qint64 relocate(qint64 id, const QString& newPath, qint64 fileSize, qint64 fileMtime); + + // --- reads ------------------------------------------------------------ + + QVector list(const Query& query) const; + int count(const Query& query) const; + + TextureRecord byId(qint64 id) const; + TextureRecord byPath(const QString& path) const; + + // --- tags ------------------------------------------------------------- + + QStringList tags(qint64 id) const; + bool addTag(qint64 id, const QString& tag); + bool removeTag(qint64 id, const QString& tag); + +private: + enum class Stamp { Opened, Saved }; + + bool createSchema(); + bool migrate(int fromVersion); + int readSchemaVersion(); + bool writeSchemaVersion(int version); + + bool upsert(TextureRecord& rec, Stamp stamp, qint64 whenMs); + + static TextureRecord readRow(const class QSqlQuery& query); + static QString orderByClause(const Query& query); + static QString whereClause(const Query& query); + + Database db; + bool readOnly = false; + int currentVersion = 0; +}; + +} // namespace catalog diff --git a/src/catalog/database.cpp b/src/catalog/database.cpp new file mode 100644 index 00000000..0172c846 --- /dev/null +++ b/src/catalog/database.cpp @@ -0,0 +1,279 @@ +#include "database.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace catalog { + +namespace { + +// Qt keys connections by name in a process-wide registry, so every Database +// instance needs its own. The thread id is in the name purely to make a +// cross-thread misuse obvious in a debugger. +QString makeConnectionName() +{ + static QAtomicInteger counter(0); + return QStringLiteral("texturelab_catalog_%1_%2") + .arg(reinterpret_cast(QThread::currentThreadId()), 0, 16) + .arg(counter.fetchAndAddRelaxed(1)); +} + +bool isMemoryPath(const QString& path) +{ + return path == QLatin1String(":memory:") || path.startsWith(QLatin1String("file::memory:")); +} + +// Guards addDatabase/removeDatabase only. Qt's connection registry is a +// process-wide map, and the reconciliation pass opens its own connections from +// a worker thread while the GUI thread holds its own. Individual connections +// stay thread-confined; this just keeps two threads from mutating the registry +// at the same moment. +QMutex& registryMutex() +{ + static QMutex mutex; + return mutex; +} + +} // namespace + +Database::Database() = default; + +Database::~Database() +{ + close(); +} + +bool Database::open(const QString& path, const Options& options) +{ + close(); + + connectionName = makeConnectionName(); + dbPath = path; + + // Every QSqlDatabase copy must be destroyed before removeDatabase() runs, + // or Qt warns that the connection is still in use and leaves it registered. + // That includes the failure paths below, hence the scoping. + bool openFailed = false; + { + QMutexLocker lock(®istryMutex()); + QSqlDatabase conn = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connectionName); + conn.setDatabaseName(path); + + QStringList connectOptions; + connectOptions << QStringLiteral("QSQLITE_BUSY_TIMEOUT=%1").arg(options.busyTimeoutMs); + if (options.readOnly) + connectOptions << QStringLiteral("QSQLITE_OPEN_READONLY"); + conn.setConnectOptions(connectOptions.join(QLatin1Char(';'))); + + if (!conn.open()) { + errorText = conn.lastError().text(); + openFailed = true; + } + } + + if (openFailed) { + qWarning("catalog: could not open %s: %s", qPrintable(path), qPrintable(errorText)); + { + QMutexLocker lock(®istryMutex()); + QSqlDatabase::removeDatabase(connectionName); + } + connectionName.clear(); + return false; + } + + opened = true; + + if (!applyPragmas(options)) { + close(); + return false; + } + + return true; +} + +void Database::close() +{ + if (!connectionName.isEmpty()) { + if (transactionActive) + rollbackTransaction(); + { + QSqlDatabase db = QSqlDatabase::database(connectionName, false); + if (db.isValid() && db.isOpen()) + db.close(); + } + // The QSqlDatabase copy above must be out of scope before + // removeDatabase, or Qt warns about a connection still in use. + { + QMutexLocker lock(®istryMutex()); + QSqlDatabase::removeDatabase(connectionName); + } + connectionName.clear(); + } + opened = false; + transactionActive = false; + dbPath.clear(); +} + +bool Database::isOpen() const +{ + return opened && handle().isOpen(); +} + +QSqlDatabase Database::handle() const +{ + return QSqlDatabase::database(connectionName, false); +} + +bool Database::applyPragmas(const Options& options) +{ + const bool memory = isMemoryPath(dbPath); + + // Order matters and is not negotiable. page_size and auto_vacuum can only + // take effect on a database with no pages yet, and journal_mode=WAL writes + // a page — so both must precede it. Getting this backwards fails silently: + // the PRAGMA reports success and the setting simply doesn't apply. + if (!options.readOnly && !memory) { + if (options.pageSize > 0 + && !exec(QStringLiteral("PRAGMA page_size = %1").arg(options.pageSize))) + return false; + + if (options.incrementalAutoVacuum && !exec(QStringLiteral("PRAGMA auto_vacuum = INCREMENTAL"))) + return false; + + if (options.walMode) { + if (!exec(QStringLiteral("PRAGMA journal_mode = WAL"))) + return false; + if (!exec(QStringLiteral("PRAGMA synchronous = NORMAL"))) + return false; + } + } + + if (options.foreignKeys && !exec(QStringLiteral("PRAGMA foreign_keys = ON"))) + return false; + + return true; +} + +bool Database::exec(const QString& sql) +{ + QSqlQuery query(handle()); + if (!query.exec(sql)) { + errorText = query.lastError().text(); + qWarning("catalog: query failed: %s [%s]", qPrintable(errorText), qPrintable(sql)); + return false; + } + return true; +} + +bool Database::execBatch(const QStringList& statements) +{ + Transaction tx(*this); + if (!tx.isActive()) + return false; + + for (const QString& sql : statements) { + if (!exec(sql)) + return false; + } + + return tx.commit(); +} + +qint64 Database::scalar(const QString& sql, qint64 fallback) +{ + QSqlQuery query(handle()); + if (!query.exec(sql) || !query.next()) + return fallback; + + const QVariant value = query.value(0); + return value.isNull() ? fallback : value.toLongLong(); +} + +QSqlQuery Database::prepare(const QString& sql) +{ + QSqlQuery query(handle()); + if (!query.prepare(sql)) { + errorText = query.lastError().text(); + qWarning("catalog: prepare failed: %s [%s]", qPrintable(errorText), qPrintable(sql)); + } + return query; +} + +bool Database::beginTransaction() +{ + if (transactionActive) { + errorText = QStringLiteral("transaction already active"); + return false; + } + // BEGIN IMMEDIATE takes the write lock up front. The default deferred + // transaction takes it at the first write, which under WAL can fail with + // SQLITE_BUSY partway through a batch that already read data — the classic + // "upgrade deadlock" that busy_timeout cannot resolve. + if (!exec(QStringLiteral("BEGIN IMMEDIATE"))) + return false; + + transactionActive = true; + return true; +} + +bool Database::commitTransaction() +{ + if (!transactionActive) { + errorText = QStringLiteral("no transaction to commit"); + return false; + } + const bool ok = exec(QStringLiteral("COMMIT")); + transactionActive = false; + return ok; +} + +bool Database::rollbackTransaction() +{ + if (!transactionActive) + return false; + + const bool ok = exec(QStringLiteral("ROLLBACK")); + transactionActive = false; + return ok; +} + +Transaction::Transaction(Database& database) : db(&database) +{ + if (db->inTransaction()) { + qWarning("catalog: nested transaction requested; inner scope is inert"); + return; + } + active = db->beginTransaction(); +} + +Transaction::~Transaction() +{ + if (active) + rollback(); +} + +bool Transaction::commit() +{ + if (!active) + return false; + + active = false; + return db->commitTransaction(); +} + +void Transaction::rollback() +{ + if (!active) + return; + + active = false; + db->rollbackTransaction(); +} + +} // namespace catalog diff --git a/src/catalog/database.h b/src/catalog/database.h new file mode 100644 index 00000000..d760bcb5 --- /dev/null +++ b/src/catalog/database.h @@ -0,0 +1,109 @@ +#pragma once + +#include +#include + +class QSqlQuery; + +namespace catalog { + +// A single SQLite connection, opened through Qt's bundled QSQLITE driver. +// +// One connection per thread, never shared: QSqlDatabase handles are tied to the +// thread that opened them, and WAL only makes concurrent *connections* safe, not +// concurrent use of one handle. Each Database instance registers its own +// uniquely-named Qt connection, so several can coexist over the same file. +class Database { +public: + struct Options { + // Applied before any other PRAGMA and before any DDL. SQLite can only + // honor these on an empty database file: page_size otherwise needs a + // full VACUUM to take effect, and auto_vacuum cannot be raised from + // NONE at all without one. 0 leaves the driver default in place. + int pageSize = 0; + bool incrementalAutoVacuum = false; + + bool walMode = true; + bool foreignKeys = true; + int busyTimeoutMs = 5000; + bool readOnly = false; + }; + + Database(); + ~Database(); + + Database(const Database&) = delete; + Database& operator=(const Database&) = delete; + + // `path` may be ":memory:" for a private in-memory database, which is what + // the tests use. Options that only apply to a file (page_size, WAL) are + // skipped in that case rather than failing. + bool open(const QString& path, const Options& options); + + // Spelled as an overload rather than a defaulted argument: a default + // argument of `Options()` would need the nested struct's member + // initializers before the enclosing class is complete. + bool open(const QString& path) { return open(path, Options()); } + void close(); + + bool isOpen() const; + QString path() const { return dbPath; } + QSqlDatabase handle() const; + + // Runs a statement that takes no parameters and returns no rows. On failure + // the driver's message is recorded in lastError() and logged. + bool exec(const QString& sql); + + // Runs several statements in one transaction, stopping at the first + // failure. Used for schema creation. + bool execBatch(const QStringList& statements); + + // Single-value query; returns `fallback` on any failure or empty result. + qint64 scalar(const QString& sql, qint64 fallback = 0); + + // Prepared statement bound to this connection. Always check isValid() on + // the returned query — a prepare failure is reported here, not at exec(). + QSqlQuery prepare(const QString& sql); + + bool beginTransaction(); + bool commitTransaction(); + bool rollbackTransaction(); + bool inTransaction() const { return transactionActive; } + + QString lastError() const { return errorText; } + void setError(const QString& text) { errorText = text; } + +private: + bool applyPragmas(const Options& options); + + QString connectionName; + QString dbPath; + QString errorText; + bool opened = false; + bool transactionActive = false; +}; + +// RAII transaction. Rolls back on destruction unless commit() succeeded, so an +// early return or a failed step can never leave a half-written batch behind. +// +// Does not nest: constructing one while another is active is a programming +// error, and the inner instance becomes inert rather than committing the outer +// transaction out from under it. +class Transaction { +public: + explicit Transaction(Database& db); + ~Transaction(); + + Transaction(const Transaction&) = delete; + Transaction& operator=(const Transaction&) = delete; + + bool isActive() const { return active; } + bool commit(); + void rollback(); + +private: + Database* db = nullptr; + bool active = false; +}; + +} // namespace catalog diff --git a/src/catalog/texturerecord.h b/src/catalog/texturerecord.h new file mode 100644 index 00000000..bcf360ad --- /dev/null +++ b/src/catalog/texturerecord.h @@ -0,0 +1,88 @@ +#pragma once + +#include +#include +#include + +namespace catalog { + +// Output channel bits, mirroring TextureChannel in src/texturelab/models.h. +// +// Deliberately redeclared instead of including models.h: the catalog library +// must not depend on the app's node graph, so that it stays testable on its own +// and so that a change to the graph model can't quietly alter what's already +// stored in the index. The mapping between the two lives at the app boundary +// (Phase 2), and the ordinals below must not be renumbered — they're persisted. +enum ChannelBit : int { + ChannelNone = 0, + ChannelAlbedo = 1 << 1, + ChannelNormal = 1 << 2, + ChannelMetalness = 1 << 3, + ChannelRoughness = 1 << 4, + ChannelHeight = 1 << 5, + ChannelAlpha = 1 << 6, + ChannelAO = 1 << 7, +}; + +// One row of the texture table. +// +// Timestamps are Unix milliseconds. 0 means "unset" and is written to the +// database as NULL — the partial index on recents and every `IS NULL` filter +// depend on that distinction, so don't start storing a real 0. +// +// There is deliberately no content hash. Change detection is (file_size, +// file_mtime) from stat(), which is what actually decides whether a file was +// edited outside the app; a hash would only have been a cache key, and the +// cache keys on `id` instead. +struct TextureRecord { + qint64 id = -1; + QString path; + QString name; + qint64 fileSize = 0; + qint64 fileMtime = 0; + int width = 0; + int height = 0; + int nodeCount = 0; + QString libVersion; + int channels = ChannelNone; + qint64 createdAt = 0; + qint64 lastOpened = 0; + qint64 lastSaved = 0; + bool starred = false; + qint64 missingSince = 0; + + bool isValid() const { return id >= 0; } + bool isMissing() const { return missingSince != 0; } + bool hasChannel(ChannelBit bit) const { return (channels & bit) != 0; } +}; + +// Which set of rows a query covers. +enum class Filter { + All, // everything, including missing files (they render dimmed) + Recents, // opened at least once, excluding missing — "jump back in" + Starred, // starred, including missing +}; + +enum class SortKey { + Modified, // file_mtime — what the card's relative time shows + Opened, // last_opened + Name, + Size, +}; + +struct Query { + Filter filter = Filter::All; + SortKey sort = SortKey::Modified; + bool ascending = false; + + // Case-insensitive substring match against name and tags. Empty disables + // the filter entirely rather than matching everything, so the common path + // doesn't pay for the tag subquery. + QString search; + + // limit < 0 means unbounded. The grid pages, so it normally doesn't. + int limit = -1; + int offset = 0; +}; + +} // namespace catalog diff --git a/src/catalog/thumbnailcache.cpp b/src/catalog/thumbnailcache.cpp new file mode 100644 index 00000000..b62a7b1b --- /dev/null +++ b/src/catalog/thumbnailcache.cpp @@ -0,0 +1,328 @@ +#include "thumbnailcache.h" + +#include +#include +#include +#include +#include + +namespace catalog { + +namespace { + +const char* sourceToText(ThumbSource source) +{ + return source == ThumbSource::Save ? "save" : "open"; +} + +Database::Options cacheOptions() +{ + Database::Options options; + // 8 KiB pages suit rows that are mostly a JPEG blob — fewer overflow pages + // per image than the 4 KiB default. Both this and auto_vacuum only take + // effect on an empty file, which is why the cache is recreated rather than + // migrated when anything is wrong with it. + options.pageSize = 8192; + options.incrementalAutoVacuum = true; + options.walMode = true; + options.foreignKeys = false; + return options; +} + +} // namespace + +ThumbnailCache::ThumbnailCache() = default; + +ThumbnailCache::~ThumbnailCache() +{ + close(); +} + +bool ThumbnailCache::open(const QString& path) +{ + close(); + + if (!db.open(path, cacheOptions())) + return recreate(path); + + const bool fresh = db.scalar( + QStringLiteral("SELECT count(*) FROM sqlite_master WHERE type='table'")) + == 0; + + if (fresh) + return createSchema(); + + if (readSchemaVersion() != SchemaVersion) { + qInfo("catalog: thumbnail cache schema mismatch; rebuilding %s", qPrintable(path)); + return recreate(path); + } + + return true; +} + +bool ThumbnailCache::recreate(const QString& path) +{ + db.close(); + + // A cache has no history worth saving, so anything unexpected — a corrupt + // file, an unreadable one, a schema from another build — is resolved by + // starting over. Delete the WAL sidecars too, or SQLite will try to replay + // them into the new file. + QFile::remove(path); + QFile::remove(path + QStringLiteral("-wal")); + QFile::remove(path + QStringLiteral("-shm")); + + if (!db.open(path, cacheOptions())) + return false; + + return createSchema(); +} + +bool ThumbnailCache::createSchema() +{ + QStringList statements; + + statements << QStringLiteral(R"( + CREATE TABLE thumb ( + texture_id INTEGER NOT NULL, + mesh TEXT NOT NULL DEFAULT 'default', + hdri TEXT NOT NULL DEFAULT 'default', + size INTEGER NOT NULL, + format TEXT NOT NULL DEFAULT 'jpg', + source TEXT NOT NULL, + bytes BLOB NOT NULL, + created_at INTEGER NOT NULL, + last_used INTEGER NOT NULL, + PRIMARY KEY (texture_id, mesh, hdri, size) + ) WITHOUT ROWID + )"); + + statements << QStringLiteral("CREATE INDEX ix_evict ON thumb(last_used)"); + statements << QStringLiteral("CREATE TABLE meta (k TEXT PRIMARY KEY, v TEXT)"); + statements << QStringLiteral("INSERT INTO meta (k, v) VALUES ('schema_version', '%1')") + .arg(SchemaVersion); + + return db.execBatch(statements); +} + +int ThumbnailCache::readSchemaVersion() +{ + QSqlQuery query = db.prepare(QStringLiteral("SELECT v FROM meta WHERE k = 'schema_version'")); + if (!query.exec() || !query.next()) + return 0; + + return query.value(0).toInt(); +} + +void ThumbnailCache::close() +{ + db.close(); +} + +bool ThumbnailCache::put(const ThumbKey& key, const QByteArray& bytes, ThumbSource source, + qint64 whenMs) +{ + Entry entry; + entry.key = key; + entry.bytes = bytes; + entry.source = source; + return putBatch({entry}, whenMs); +} + +bool ThumbnailCache::putBatch(const QVector& entries, qint64 whenMs) +{ + if (entries.isEmpty()) + return true; + + Transaction tx(db); + if (!tx.isActive()) + return false; + + for (const Entry& entry : entries) { + if (!entry.key.isValid() || entry.bytes.isEmpty()) { + db.setError(QStringLiteral("refusing to cache an empty or unkeyed thumbnail")); + return false; + } + + // created_at is preserved on conflict so the row keeps its original + // provenance; last_used moves forward because we just produced it. + QSqlQuery query = db.prepare(QStringLiteral(R"( + INSERT INTO thumb (texture_id, mesh, hdri, size, format, source, + bytes, created_at, last_used) + VALUES (?, ?, ?, ?, 'jpg', ?, ?, ?, ?) + ON CONFLICT(texture_id, mesh, hdri, size) DO UPDATE SET + source = excluded.source, + bytes = excluded.bytes, + last_used = excluded.last_used + )")); + + query.addBindValue(entry.key.textureId); + query.addBindValue(entry.key.mesh); + query.addBindValue(entry.key.hdri); + query.addBindValue(entry.key.size); + query.addBindValue(QString::fromLatin1(sourceToText(entry.source))); + query.addBindValue(entry.bytes); + query.addBindValue(whenMs); + query.addBindValue(whenMs); + + if (!query.exec()) { + db.setError(query.lastError().text()); + qWarning("catalog: thumbnail write failed: %s", qPrintable(db.lastError())); + return false; + } + } + + return tx.commit(); +} + +QByteArray ThumbnailCache::get(const ThumbKey& key) const +{ + if (!key.isValid()) + return QByteArray(); + + QSqlQuery query = const_cast(db).prepare(QStringLiteral( + "SELECT bytes FROM thumb WHERE texture_id = ? AND mesh = ? AND hdri = ? AND size = ?")); + query.addBindValue(key.textureId); + query.addBindValue(key.mesh); + query.addBindValue(key.hdri); + query.addBindValue(key.size); + + if (!query.exec() || !query.next()) + return QByteArray(); + + return query.value(0).toByteArray(); +} + +bool ThumbnailCache::contains(const ThumbKey& key) const +{ + if (!key.isValid()) + return false; + + QSqlQuery query = const_cast(db).prepare(QStringLiteral( + "SELECT 1 FROM thumb WHERE texture_id = ? AND mesh = ? AND hdri = ? AND size = ?")); + query.addBindValue(key.textureId); + query.addBindValue(key.mesh); + query.addBindValue(key.hdri); + query.addBindValue(key.size); + + return query.exec() && query.next(); +} + +bool ThumbnailCache::touch(const QVector& keys, qint64 whenMs) +{ + if (keys.isEmpty()) + return true; + + Transaction tx(db); + if (!tx.isActive()) + return false; + + for (const ThumbKey& key : keys) { + QSqlQuery query = db.prepare(QStringLiteral( + "UPDATE thumb SET last_used = ? " + "WHERE texture_id = ? AND mesh = ? AND hdri = ? AND size = ?")); + query.addBindValue(whenMs); + query.addBindValue(key.textureId); + query.addBindValue(key.mesh); + query.addBindValue(key.hdri); + query.addBindValue(key.size); + + if (!query.exec()) { + db.setError(query.lastError().text()); + return false; + } + } + + return tx.commit(); +} + +int ThumbnailCache::removeTexture(qint64 textureId) +{ + if (textureId < 0) + return 0; + + QSqlQuery query = db.prepare(QStringLiteral("DELETE FROM thumb WHERE texture_id = ?")); + query.addBindValue(textureId); + + if (!query.exec()) { + db.setError(query.lastError().text()); + return 0; + } + return query.numRowsAffected(); +} + +qint64 ThumbnailCache::totalBytes() const +{ + return const_cast(db).scalar( + QStringLiteral("SELECT coalesce(sum(length(bytes)), 0) FROM thumb")); +} + +int ThumbnailCache::rowCount() const +{ + return static_cast( + const_cast(db).scalar(QStringLiteral("SELECT count(*) FROM thumb"))); +} + +int ThumbnailCache::evictTo(qint64 budgetBytes) +{ + qint64 total = totalBytes(); + if (total <= budgetBytes) + return 0; + + // Walk oldest-first, deleting until we're under budget. Done in one + // transaction so a crash mid-eviction can't leave a partially reaped cache + // — not that it would matter much, but the free-page accounting below + // assumes the deletes actually landed. + QVector doomed; + qint64 freed = 0; + + { + QSqlQuery scan = db.prepare(QStringLiteral( + "SELECT texture_id, mesh, hdri, size, length(bytes) FROM thumb " + "ORDER BY last_used ASC")); + if (!scan.exec()) { + db.setError(scan.lastError().text()); + return 0; + } + + while (scan.next() && (total - freed) > budgetBytes) { + doomed << scan.value(0) << scan.value(1) << scan.value(2) << scan.value(3); + freed += scan.value(4).toLongLong(); + } + } + + if (doomed.isEmpty()) + return 0; + + Transaction tx(db); + if (!tx.isActive()) + return 0; + + int deleted = 0; + for (int i = 0; i + 3 < doomed.size(); i += 4) { + QSqlQuery query = db.prepare(QStringLiteral( + "DELETE FROM thumb WHERE texture_id = ? AND mesh = ? AND hdri = ? AND size = ?")); + query.addBindValue(doomed[i]); + query.addBindValue(doomed[i + 1]); + query.addBindValue(doomed[i + 2]); + query.addBindValue(doomed[i + 3]); + + if (!query.exec()) { + db.setError(query.lastError().text()); + return 0; + } + deleted += query.numRowsAffected(); + } + + if (!tx.commit()) + return 0; + + // Hand the freed pages back to the filesystem. Incremental rather than a + // full VACUUM so this stays bounded; auto_vacuum was set to INCREMENTAL at + // creation precisely so this call works at all. + db.exec(QStringLiteral("PRAGMA incremental_vacuum")); + + return deleted; +} + +} // namespace catalog diff --git a/src/catalog/thumbnailcache.h b/src/catalog/thumbnailcache.h new file mode 100644 index 00000000..c11814bb --- /dev/null +++ b/src/catalog/thumbnailcache.h @@ -0,0 +1,109 @@ +#pragma once + +#include "database.h" + +#include +#include +#include + +namespace catalog { + +// Identifies one cached image. +// +// Keyed by the index's texture id. An earlier draft keyed on a hash of the +// file's contents so that copies shared a thumbnail and edits invalidated +// themselves — but change detection turned out to be (file_size, file_mtime) +// from stat() either way, and the hash was only ever the key. Dropping it +// removed a dependency and a column for the price of one extra render the +// first time you open a copied file. +// +// The consequence is that this cache is coupled to index.db's row ids: delete +// the index and these rows are orphaned. That's acceptable precisely because +// the cache is disposable — clear it alongside. +struct ThumbKey { + qint64 textureId = -1; + QString mesh = QStringLiteral("default"); + QString hdri = QStringLiteral("default"); + int size = 256; + + bool isValid() const { return textureId >= 0 && size > 0; } +}; + +// Where a cached image came from. Lets a better capture supersede a cheaper one +// instead of the cache locking in whatever was written first. +enum class ThumbSource { + Save, // captured from the 3D viewport at save time — the good one + Open, // captured on open, for a file indexed before this feature existed +}; + +// Repository over thumbs.db — a pure cache. +// +// Deleting this file at any time must be harmless: it is rebuilt as the user +// saves and opens textures. That's why it lives apart from index.db, which +// cannot be rebuilt at all (see LAUNCHER_PRD.md §1.1), and why a schema +// mismatch here is handled by deleting the file rather than migrating it. +class ThumbnailCache { +public: + static constexpr int SchemaVersion = 1; + + // Roughly one 512px and one 256px JPEG per texture, so this is generous. + static constexpr qint64 DefaultBudgetBytes = 512LL * 1024 * 1024; + + ThumbnailCache(); + ~ThumbnailCache(); + + ThumbnailCache(const ThumbnailCache&) = delete; + ThumbnailCache& operator=(const ThumbnailCache&) = delete; + + // Recreates the file from scratch if it's missing, corrupt, or written to a + // different schema version. Only returns false if even that fails. + bool open(const QString& path); + void close(); + + bool isOpen() const { return db.isOpen(); } + QString lastError() const { return db.lastError(); } + + bool put(const ThumbKey& key, const QByteArray& bytes, ThumbSource source, qint64 whenMs); + + // Writes many images in one transaction. One transaction per thumbnail + // means one fsync per thumbnail, which is what makes a bulk write crawl. + struct Entry { + ThumbKey key; + QByteArray bytes; + ThumbSource source = ThumbSource::Save; + }; + bool putBatch(const QVector& entries, qint64 whenMs); + + // Returns an empty QByteArray on a miss. Deliberately does not update + // last_used: this runs during scroll, and a write per painted card is + // exactly what the "no disk writes on the GUI thread" rule forbids. Call + // touch() later with what was actually used. + QByteArray get(const ThumbKey& key) const; + + bool contains(const ThumbKey& key) const; + + // Batched last_used bookkeeping, flushed on idle or close. Day-granularity + // timestamps are plenty for an LRU whose eviction budget is half a gigabyte. + bool touch(const QVector& keys, qint64 whenMs); + + // Drops every variant of one texture. Called when reconciliation sees a + // file's size or mtime change — the cached image no longer shows what's on + // disk — and when a texture is removed from the launcher. + int removeTexture(qint64 textureId); + + qint64 totalBytes() const; + int rowCount() const; + + // Deletes least-recently-used rows until the cache fits in `budgetBytes`, + // then returns pages to the filesystem. Returns the number of rows deleted. + int evictTo(qint64 budgetBytes = DefaultBudgetBytes); + +private: + bool createSchema(); + bool recreate(const QString& path); + int readSchemaVersion(); + + Database db; +}; + +} // namespace catalog diff --git a/src/colorpicker/colorpicker.cpp b/src/colorpicker/colorpicker.cpp index c12afe18..14dada89 100644 --- a/src/colorpicker/colorpicker.cpp +++ b/src/colorpicker/colorpicker.cpp @@ -1,17 +1,26 @@ #include "colorpicker.h" #include "./widgets.h" -#include +#include #include #include #include +#include #include #include -#include #include #include ColorPicker::ColorPicker() { + // Frameless tool window instead of Qt::Popup: Qt::Popup does an X11 + // keyboard/pointer grab to detect outside clicks, which also blocks + // global WM shortcuts (e.g. PrintScreen) while it's open. Outside + // clicks are instead detected manually via the app-wide event filter + // below, which doesn't require any grab. + setWindowFlags(Qt::Tool | Qt::FramelessWindowHint + | Qt::WindowStaysOnTopHint); + qApp->installEventFilter(this); + svBox = new SVBox(); hueSlider = new HueSlider(); // alphaSlider = new AlphaSlider(); @@ -38,23 +47,10 @@ ColorPicker::ColorPicker() vlayout->addWidget(hueSlider); // vlayout->addWidget(alphaSlider); - // add OK and Cancel buttons - auto buttonBox = - new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); - connect(buttonBox, &QDialogButtonBox::rejected, this, [this]() { - // Revert to original color on cancel - svBox->setColor(originalColor); - hueSlider->setColor(originalColor); - emit onColorChanged(originalColor); - QDialog::reject(); - }); - vlayout->addWidget(buttonBox); - this->setLayout(vlayout); // this->setBaseSize(400, 500); - this->resize(400, 330); + this->resize(400, 300); } void ColorPicker::setColor(const QColor& color) @@ -63,4 +59,53 @@ void ColorPicker::setColor(const QColor& color) svBox->setColor(color); hueSlider->setColor(color); // alphaSlider->setColor(color); -} \ No newline at end of file +} + +void ColorPicker::cancel() +{ + // revert to the color the dialog was opened with + svBox->setColor(originalColor); + hueSlider->setColor(originalColor); + emit onColorChanged(originalColor); + reject(); +} + +void ColorPicker::keyPressEvent(QKeyEvent* event) +{ + if (event->key() == Qt::Key_Escape) { + cancel(); + return; + } + + event->ignore(); + + // QDialog::keyPressEvent(event); +} + +void ColorPicker::hideEvent(QHideEvent* event) +{ + QDialog::hideEvent(event); + emit onClosed(); +} + +void ColorPicker::showEvent(QShowEvent* event) +{ + QDialog::showEvent(event); + // Tool windows aren't always given keyboard focus by the window + // manager on their own, unlike Qt::Popup; claim it explicitly so + // Escape reaches us. + raise(); + activateWindow(); +} + +bool ColorPicker::eventFilter(QObject* watched, QEvent* event) +{ + if (event->type() == QEvent::MouseButtonPress) { + auto widget = qobject_cast(watched); + if (widget && widget != this && !this->isAncestorOf(widget)) { + close(); + } + } + + return QDialog::eventFilter(watched, event); +} diff --git a/src/colorpicker/colorpicker.h b/src/colorpicker/colorpicker.h index e1829d4c..fcb6e26a 100644 --- a/src/colorpicker/colorpicker.h +++ b/src/colorpicker/colorpicker.h @@ -4,6 +4,9 @@ class SVBox; class HueSlider; class AlphaSlider; +class QKeyEvent; +class QHideEvent; +class QShowEvent; class ColorPicker : public QDialog { Q_OBJECT @@ -16,10 +19,17 @@ class ColorPicker : public QDialog { void onColorChanged(const QColor& color); void onClosed(); +protected: + void keyPressEvent(QKeyEvent* event) override; + void hideEvent(QHideEvent* event) override; + void showEvent(QShowEvent* event) override; + bool eventFilter(QObject* watched, QEvent* event) override; + private: void initUI(); void colorChangedByEditor(QColor color); void colorChangedByUI(QColor color); + void cancel(); SVBox* svBox; HueSlider* hueSlider; diff --git a/src/icons/chevron-down.svg b/src/icons/chevron-down.svg new file mode 100644 index 00000000..6a16f720 --- /dev/null +++ b/src/icons/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/chevron-up.svg b/src/icons/chevron-up.svg new file mode 100644 index 00000000..f56d0b50 --- /dev/null +++ b/src/icons/chevron-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/icons/copy.svg b/src/icons/copy.svg index fa72d79c..daed7d43 100644 --- a/src/icons/copy.svg +++ b/src/icons/copy.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/src/icons/crosshair.svg b/src/icons/crosshair.svg index ef26448c..016b5794 100644 --- a/src/icons/crosshair.svg +++ b/src/icons/crosshair.svg @@ -1,4 +1,7 @@ - - - + + + + + + diff --git a/src/icons/download.svg b/src/icons/download.svg new file mode 100644 index 00000000..0b25b2e5 --- /dev/null +++ b/src/icons/download.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/icons/export.svg b/src/icons/export.svg new file mode 100644 index 00000000..9effdc40 --- /dev/null +++ b/src/icons/export.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/icons/grid.svg b/src/icons/grid.svg index 39ccf3f0..9c5bb650 100644 --- a/src/icons/grid.svg +++ b/src/icons/grid.svg @@ -1,4 +1,6 @@ - - - + + + + + diff --git a/src/icons/redo.svg b/src/icons/redo.svg new file mode 100644 index 00000000..501cd955 --- /dev/null +++ b/src/icons/redo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/icons/save.svg b/src/icons/save.svg index db3c6667..d0952227 100644 --- a/src/icons/save.svg +++ b/src/icons/save.svg @@ -1,4 +1,5 @@ - - - + + + + diff --git a/src/icons/undo.svg b/src/icons/undo.svg new file mode 100644 index 00000000..1a562277 --- /dev/null +++ b/src/icons/undo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/nodegraph/CMakeLists.txt b/src/nodegraph/CMakeLists.txt index e165a3ce..5a5ccb3d 100644 --- a/src/nodegraph/CMakeLists.txt +++ b/src/nodegraph/CMakeLists.txt @@ -28,11 +28,13 @@ set(NODEGRAPH_HEADERS # library add_library(nodegraph STATIC ${NODEGRAPH_SRCS} ${NODEGRAPH_HEADERS}) -target_link_libraries(nodegraph PRIVATE Qt${QT_VERSION_MAJOR}::Core - Qt${QT_VERSION_MAJOR}::Gui - Qt${QT_VERSION_MAJOR}::OpenGLWidgets +target_link_libraries(nodegraph PRIVATE Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Gui + Qt${QT_VERSION_MAJOR}::OpenGLWidgets Qt${QT_VERSION_MAJOR}::Widgets - OpenGL::GL) + OpenGL::GL + theme) +target_include_directories(nodegraph PRIVATE ${CMAKE_SOURCE_DIR}/src/theme) set_target_properties(nodegraph PROPERTIES @@ -63,8 +65,10 @@ add_executable(nodegraph_app ) target_link_libraries(nodegraph_app PRIVATE Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Gui - Qt${QT_VERSION_MAJOR}::OpenGLWidgets - Qt${QT_VERSION_MAJOR}::Widgets) + Qt${QT_VERSION_MAJOR}::OpenGLWidgets + Qt${QT_VERSION_MAJOR}::Widgets + theme) +target_include_directories(nodegraph_app PRIVATE ${CMAKE_SOURCE_DIR}/src/theme) set_target_properties(nodegraph_app PROPERTIES diff --git a/src/nodegraph/graph/comment.cpp b/src/nodegraph/graph/comment.cpp index 63f4cc35..2733565b 100644 --- a/src/nodegraph/graph/comment.cpp +++ b/src/nodegraph/graph/comment.cpp @@ -1,4 +1,5 @@ #include "comment.h" +#include "nodetheme.h" #include "scene.h" #include #include @@ -63,21 +64,21 @@ void Comment::paint(QPainter* painter, const QStyleOptionGraphicsItem* option, const QRectF rect = calcTextRect(); - // Semi-transparent white background - painter->setBrush(QColor(255, 255, 255, 30)); + // Semi-transparent fill + painter->setBrush(ntColor(Tokens::CommentFill, 30)); painter->setPen(Qt::NoPen); painter->drawRoundedRect(rect, 4, 4); - // White border - QPen borderPen(QColor(255, 255, 255, isSelected() ? 220 : 140), 1.0); + // Border + QPen borderPen(ntColor(Tokens::CommentFill, isSelected() ? 220 : 140), 1.0); painter->setPen(borderPen); painter->setBrush(Qt::NoBrush); painter->drawRoundedRect(rect, 4, 4); - // White text + // Text QFont font("Arial", FONT_SIZE); painter->setFont(font); - painter->setPen(QColor(240, 240, 240)); + painter->setPen(ntColor(Tokens::CommentText)); QFontMetrics fm(font); const QStringList lines = _text.split('\n'); diff --git a/src/nodegraph/graph/frame.cpp b/src/nodegraph/graph/frame.cpp index 3845f2c6..0de12312 100644 --- a/src/nodegraph/graph/frame.cpp +++ b/src/nodegraph/graph/frame.cpp @@ -1,4 +1,5 @@ #include "frame.h" +#include "nodetheme.h" #include "scene.h" #include #include @@ -204,15 +205,22 @@ void Frame::paint(QPainter* painter, const QStyleOptionGraphicsItem* option, // Draw title if enabled if (_showTitle && !_title.isEmpty()) { - painter->setPen(QColor(255, 255, 255)); - painter->setFont(QFont("Arial", 10, QFont::Bold)); + QFont font("Arial", 10, QFont::Bold); + painter->setFont(font); + + // shadow pass + painter->setPen(ntColor(Tokens::NodeBorder, 160)); + painter->drawText(handleRect.translated(1, 1), Qt::AlignCenter, _title); + + // text pass + painter->setPen(ntColor(Tokens::NodeTitle)); painter->drawText(handleRect, Qt::AlignCenter, _title); } // Draw frame border QPen borderPen; if (isSelected()) { - borderPen.setColor(QColor(255, 165, 0)); // Orange for selected + borderPen.setColor(ntColor(Tokens::FrameSelect)); // themed "selected" accent borderPen.setWidth(2); } else { @@ -229,7 +237,7 @@ void Frame::paint(QPainter* painter, const QStyleOptionGraphicsItem* option, // Draw resize handles when selected if (isSelected()) { - painter->setBrush(QColor(100, 100, 100, 100)); + painter->setBrush(QColor(100, 100, 100, 100)); // theme-exempt: neutral resize-handle overlay painter->setPen(Qt::NoPen); qreal h = RESIZE_HANDLE_SIZE; diff --git a/src/nodegraph/graph/nodetheme.h b/src/nodegraph/graph/nodetheme.h new file mode 100644 index 00000000..678f9704 --- /dev/null +++ b/src/nodegraph/graph/nodetheme.h @@ -0,0 +1,24 @@ +#pragma once + +// Convenience accessors so the node-graph paint code (surface C: QSS can't reach +// QGraphicsItem painting) can pull colors from the shared theme with one call. +// Colors are read at paint time, so they follow theme changes / --dev-theme +// hot-reload as soon as the view repaints (see NodeGraph's themeChanged hookup). + +#include "thememanager.h" +#include "tokens.h" + +#include + +inline QColor ntColor(const char* token) +{ + return ThemeManager::instance().theme().color(token); +} + +// Same, with an explicit alpha for translucent overlays (socket labels, etc.). +inline QColor ntColor(const char* token, int alpha) +{ + QColor c = ThemeManager::instance().theme().color(token); + c.setAlpha(alpha); + return c; +} diff --git a/src/nodegraph/graph/scene.cpp b/src/nodegraph/graph/scene.cpp index 6d8c96fa..367ece96 100644 --- a/src/nodegraph/graph/scene.cpp +++ b/src/nodegraph/graph/scene.cpp @@ -1,12 +1,13 @@ #include "scene.h" #include "comment.h" #include "frame.h" +#include "nodetheme.h" #include #include #include #include -#include #include +#include #include #include #include @@ -25,14 +26,16 @@ bool Node::glInitialized = false; void Node::initializeGL() { - if (glInitialized) return; - + if (glInitialized) + return; + QOpenGLContext* ctx = QOpenGLContext::currentContext(); - if (!ctx) return; - + if (!ctx) + return; + // Create shader program shaderProgram = new QOpenGLShaderProgram(); - + const char* vertexShaderSource = R"( #version 150 in vec2 position; @@ -44,51 +47,60 @@ void Node::initializeGL() vTexCoord = texCoord; } )"; - + const char* fragmentShaderSource = R"( #version 150 in vec2 vTexCoord; out vec4 fragColor; uniform sampler2D textureSampler; void main() { - fragColor = texture(textureSampler, vTexCoord); + // 8px checkerboard in screen space + vec2 tile = floor(gl_FragCoord.xy / 8.0); + float checker = mod(tile.x + tile.y, 2.0); + vec3 bg = mix(vec3(0.753), vec3(0.502), checker); + + vec4 texColor = texture(textureSampler, vTexCoord); + fragColor = vec4(mix(bg, texColor.rgb, texColor.a), 1.0); } )"; - - shaderProgram->addShaderFromSourceCode(QOpenGLShader::Vertex, vertexShaderSource); - shaderProgram->addShaderFromSourceCode(QOpenGLShader::Fragment, fragmentShaderSource); + + shaderProgram->addShaderFromSourceCode(QOpenGLShader::Vertex, + vertexShaderSource); + shaderProgram->addShaderFromSourceCode(QOpenGLShader::Fragment, + fragmentShaderSource); shaderProgram->link(); - + // Create VAO and VBO vao = new QOpenGLVertexArrayObject(); vao->create(); - + vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); vbo->create(); vbo->setUsagePattern(QOpenGLBuffer::DynamicDraw); - + glInitialized = true; } void Node::cleanupGL() { - if (!glInitialized) return; - + if (!glInitialized) + return; + delete shaderProgram; shaderProgram = nullptr; - + if (vbo) { vbo->destroy(); delete vbo; vbo = nullptr; } - + if (vao) { vao->destroy(); delete vao; vao = nullptr; } - + glInitialized = false; } @@ -109,9 +121,11 @@ ConnectionPtr Scene::connectNodes(NodePtr leftNode, QString leftOutputName, NodePtr rightNode, QString rightInputName) { auto leftPort = leftNode->getOutPortByName(leftOutputName); - qDebug() << rightNode->getInPorts(); auto rightPort = rightNode->getInPortByName(rightInputName); + if (!leftPort || !rightPort) + return ConnectionPtr(nullptr); + // create new connection item from ports auto conn = new Connection(); conn->startPort = leftPort; @@ -130,7 +144,9 @@ ConnectionPtr Scene::connectNodes(NodePtr leftNode, QString leftOutputName, return connPtr; } -NodePtr Scene::getNodeById(QString id) { return nodes[id]; } +// .value() (not operator[]): a read-only lookup must never default-insert a +// null entry into the map, which paint/drag/label iteration would dereference. +NodePtr Scene::getNodeById(QString id) { return nodes.value(id); } void Scene::addFrame(FramePtr frame) { @@ -138,7 +154,7 @@ void Scene::addFrame(FramePtr frame) frames[frame->id()] = frame; } -FramePtr Scene::getFrameById(QString id) { return frames[id]; } +FramePtr Scene::getFrameById(QString id) { return frames.value(id); } void Scene::removeFrame(FramePtr frame) { @@ -154,7 +170,7 @@ void Scene::addComment(CommentPtr comment) comments[comment->id()] = comment; } -CommentPtr Scene::getCommentById(QString id) { return comments[id]; } +CommentPtr Scene::getCommentById(QString id) { return comments.value(id); } void Scene::removeComment(CommentPtr comment) { @@ -185,6 +201,8 @@ void Scene::removeNode(NodePtr node) node->hide(); // fix display cache issue this->removeItem(node.data()); + nodes.remove(node->id()); + // reshow here in case i forget when re-adding node for // undo-redo node->show(); @@ -216,11 +234,13 @@ Node::Node() width = NODE_WIDTH; height = NODE_HEIGHT; isHovered = false; + showingSocketNames = false; - defaultBorderColor = QColor(0, 0, 0); - highlightBorderColor = QColor(0, 0, 0); - // highlightBorderColor = QColor(120, 120, 120); - selectedBorderColor = QColor(200, 200, 200); + // Border colors are read from tokens at paint time (see Node::paint); these + // members are kept only for any external callers. + defaultBorderColor = ntColor(Tokens::NodeBorder); + highlightBorderColor = ntColor(Tokens::NodeBorderHover); + selectedBorderColor = ntColor(Tokens::NodeBorderSelect); setCacheMode(QGraphicsItem::NoCache); @@ -237,7 +257,7 @@ Node::Node() text->setPos(0, 0); text->setTextWidth(100); - text->setDefaultTextColor(QColor(255, 255, 255)); + text->setDefaultTextColor(ntColor(Tokens::NodeTitle)); text->setZValue(5); // center title @@ -250,11 +270,21 @@ Node::Node() font.setPixelSize(12); text->setFont(font); + channelText = new QGraphicsTextItem(this); + channelText->setFlag(QGraphicsItem::ItemIsFocusable, false); + channelText->setFlag(QGraphicsItem::ItemIsSelectable, false); + channelText->setDefaultTextColor(ntColor(Tokens::NodeChannel)); + channelText->setZValue(5); + channelText->hide(); + QFont chFont = channelText->font(); + chFont.setPixelSize(12); + channelText->setFont(chFont); + QGraphicsDropShadowEffect* effect = new QGraphicsDropShadowEffect; effect->setBlurRadius(20); effect->setXOffset(0); effect->setYOffset(0); - effect->setColor(QColor(00, 00, 00, 70)); + effect->setColor(QColor(00, 00, 00, 70)); // theme-exempt: unused shadow effect (setGraphicsEffect disabled) // setGraphicsEffect(effect); // forces node to raster remder // maybe render to node behind this to get same effect @@ -264,11 +294,25 @@ Node::Node() NodePtr Node::create() { return NodePtr(new Node()); } +void Node::setShowSocketNames(bool show) +{ + if (showingSocketNames == show) + return; + showingSocketNames = show; + update(); +} + void Node::setCenter(float x, float y) { setPos(x - NODE_WIDTH / 2.0f, y - NODE_HEIGHT / 2.0f); } +QPointF Node::getCenter() const +{ + return QPointF(pos().x() + NODE_WIDTH / 2.0f, + pos().y() + NODE_HEIGHT / 2.0f); +} + void Node::setName(QString name) { this->name = name; @@ -289,6 +333,21 @@ void Node::setThumbnail(const QPixmap& pixmap) this->update(); } +void Node::setChannel(QString ch) +{ + this->channel = ch; + if (ch.isEmpty()) { + channelText->hide(); + } + else { + channelText->setPlainText(ch.toUpper()); + QFontMetrics fm(channelText->font()); + int textW = fm.horizontalAdvance(ch.toUpper()); + channelText->setPos((width - textW) / 2.0, -20); + channelText->show(); + } +} + const QVector Node::getInPorts() const { return inPorts; } const QVector Node::getOutPorts() const { return outPorts; } @@ -351,7 +410,7 @@ PortPtr Node::getPortById(QString id) return port; } - Q_ASSERT(false); + return PortPtr(nullptr); } PortPtr Node::getInPortByName(QString name) @@ -361,7 +420,7 @@ PortPtr Node::getInPortByName(QString name) return port; } - Q_ASSERT(false); + return PortPtr(nullptr); } PortPtr Node::getOutPortByName(QString name) @@ -371,7 +430,7 @@ PortPtr Node::getOutPortByName(QString name) return port; } - Q_ASSERT(false); + return PortPtr(nullptr); } QRectF Node::boundingRect() const { return QRectF(0, 0, 100, 100); } @@ -425,11 +484,11 @@ void Node::paint(QPainter* painter, QStyleOptionGraphicsItem const* option, QColor borderColor; if (isSelected()) - borderColor = this->selectedBorderColor; + borderColor = ntColor(Tokens::NodeBorderSelect); else if (isHovered) - borderColor = this->highlightBorderColor; + borderColor = ntColor(Tokens::NodeBorderHover); else - borderColor = this->defaultBorderColor; + borderColor = ntColor(Tokens::NodeBorder); // not really needed // painter->setClipRect(option->exposedRect); @@ -461,9 +520,21 @@ void Node::paint(QPainter* painter, QStyleOptionGraphicsItem const* option, bgPath.setFillRule(Qt::WindingFill); bgPath.addRoundedRect(0, 0, nodeWidth, nodeHeight, titleRadius, titleRadius); - painter->fillPath(bgPath, QBrush(QColor(10, 10, 10, 255))); + painter->fillPath(bgPath, QBrush(ntColor(Tokens::NodeBg))); if (!thumbnail.isNull()) { + // Checkerboard background for alpha-transparent thumbnails. + // (Built once and cached, so it reflects the theme at first draw.) + static QPixmap checkerTile; + if (checkerTile.isNull()) { + checkerTile = QPixmap(16, 16); + checkerTile.fill(ntColor(Tokens::CheckerA)); + QPainter cp(&checkerTile); + cp.fillRect(0, 0, 8, 8, ntColor(Tokens::CheckerB)); + cp.fillRect(8, 8, 8, 8, ntColor(Tokens::CheckerB)); + } + painter->fillRect(QRect(0, 0, nodeWidth, nodeHeight), + QBrush(checkerTile)); painter->drawPixmap(QRect(0, 0, nodeWidth, nodeHeight), thumbnail); } @@ -474,74 +545,97 @@ void Node::paint(QPainter* painter, QStyleOptionGraphicsItem const* option, // Initialize OpenGL resources if needed initializeGL(); - + if (glInitialized && shaderProgram && vao && vbo) { QOpenGLFunctions* f = QOpenGLContext::currentContext()->functions(); - + // Get the current viewport and create orthographic projection GLint viewport[4]; f->glGetIntegerv(GL_VIEWPORT, viewport); - + // Create orthographic projection matrix QTransform transform = painter->combinedTransform(); QMatrix4x4 projectionMatrix; projectionMatrix.ortho(0, viewport[2], viewport[3], 0, -1, 1); - - // Build vertex data - transform scene coordinates to device coordinates + + // Build vertex data - transform scene coordinates to device + // coordinates QPointF p0 = transform.map(QPointF(0, 0)); QPointF p1 = transform.map(QPointF(100, 0)); QPointF p2 = transform.map(QPointF(100, 100)); QPointF p3 = transform.map(QPointF(0, 100)); - + // Two triangles for a quad: position (x,y) + texcoord (u,v) GLfloat vertices[] = { // Triangle 1 - (GLfloat)p0.x(), (GLfloat)p0.y(), 0.0f, 1.0f, - (GLfloat)p1.x(), (GLfloat)p1.y(), 1.0f, 1.0f, - (GLfloat)p2.x(), (GLfloat)p2.y(), 1.0f, 0.0f, + (GLfloat)p0.x(), + (GLfloat)p0.y(), + 0.0f, + 1.0f, + (GLfloat)p1.x(), + (GLfloat)p1.y(), + 1.0f, + 1.0f, + (GLfloat)p2.x(), + (GLfloat)p2.y(), + 1.0f, + 0.0f, // Triangle 2 - (GLfloat)p0.x(), (GLfloat)p0.y(), 0.0f, 1.0f, - (GLfloat)p2.x(), (GLfloat)p2.y(), 1.0f, 0.0f, - (GLfloat)p3.x(), (GLfloat)p3.y(), 0.0f, 0.0f, + (GLfloat)p0.x(), + (GLfloat)p0.y(), + 0.0f, + 1.0f, + (GLfloat)p2.x(), + (GLfloat)p2.y(), + 1.0f, + 0.0f, + (GLfloat)p3.x(), + (GLfloat)p3.y(), + 0.0f, + 0.0f, }; - + // Setup state f->glDisable(GL_BLEND); f->glDisable(GL_DEPTH_TEST); - + // Bind shader shaderProgram->bind(); - shaderProgram->setUniformValue("projectionMatrix", projectionMatrix); + shaderProgram->setUniformValue("projectionMatrix", + projectionMatrix); shaderProgram->setUniformValue("textureSampler", 0); - + // Bind texture f->glActiveTexture(GL_TEXTURE0); f->glBindTexture(GL_TEXTURE_2D, texId); - + // Setup VAO and VBO vao->bind(); vbo->bind(); vbo->allocate(vertices, sizeof(vertices)); - + // Setup vertex attributes int positionLoc = shaderProgram->attributeLocation("position"); int texCoordLoc = shaderProgram->attributeLocation("texCoord"); - + shaderProgram->enableAttributeArray(positionLoc); shaderProgram->enableAttributeArray(texCoordLoc); - shaderProgram->setAttributeBuffer(positionLoc, GL_FLOAT, 0, 2, 4 * sizeof(GLfloat)); - shaderProgram->setAttributeBuffer(texCoordLoc, GL_FLOAT, 2 * sizeof(GLfloat), 2, 4 * sizeof(GLfloat)); - + shaderProgram->setAttributeBuffer(positionLoc, GL_FLOAT, 0, 2, + 4 * sizeof(GLfloat)); + shaderProgram->setAttributeBuffer(texCoordLoc, GL_FLOAT, + 2 * sizeof(GLfloat), 2, + 4 * sizeof(GLfloat)); + // Draw f->glDrawArrays(GL_TRIANGLES, 0, 6); - + // Cleanup shaderProgram->disableAttributeArray(positionLoc); shaderProgram->disableAttributeArray(texCoordLoc); vbo->release(); vao->release(); shaderProgram->release(); - + f->glEnable(GL_BLEND); } @@ -555,7 +649,7 @@ void Node::paint(QPainter* painter, QStyleOptionGraphicsItem const* option, QPainterPath bgPath; bgPath.setFillRule(Qt::WindingFill); bgPath.addRoundedRect(0, 0, nodeWidth, 18, titleRadius, titleRadius); - painter->fillPath(bgPath, QBrush(QColor(0, 0, 0, 255))); + painter->fillPath(bgPath, QBrush(ntColor(Tokens::NodeBorder))); text->paint(painter, option, widget); } @@ -563,6 +657,47 @@ void Node::paint(QPainter* painter, QStyleOptionGraphicsItem const* option, // draw border painter->setPen(QPen(borderColor, 3)); painter->drawRoundedRect(rect, titleRadius, titleRadius); + + // socket name labels — shown on hover or when cursor is nearby during drag + if (isHovered || showingSocketNames) { + painter->save(); + painter->setRenderHint(QPainter::TextAntialiasing); + + QFont labelFont = painter->font(); + labelFont.setPixelSize(10); + painter->setFont(labelFont); + + QFontMetrics fm(labelFont); + const int labelH = 14; + const int pad = 3; + const int portRadius = 7; + const int gap = 4; + + auto drawLabel = [&](const QString& labelName, QPointF portPos, + bool isIn) { + int textW = fm.horizontalAdvance(labelName); + int rectW = textW + pad * 2; + qreal x = isIn ? portPos.x() + portRadius + gap + : portPos.x() - portRadius - gap - rectW; + qreal y = portPos.y() - labelH / 2.0; + + QRectF bgRect(x, y, rectW, labelH); + painter->setPen(Qt::NoPen); + painter->setBrush(ntColor(Tokens::NodeBorder, 160)); + painter->drawRoundedRect(bgRect, 3, 3); + + painter->setPen(ntColor(Tokens::NodeTitle, 220)); + painter->drawText(bgRect, Qt::AlignCenter, labelName); + }; + + for (auto& port : inPorts) + drawLabel(port->name, port->pos(), true); + + // for (auto& port : outPorts) + // drawLabel(port->name, port->pos(), false); + + painter->restore(); + } } Node::~Node() @@ -630,7 +765,7 @@ void Port::paint(QPainter* painter, QStyleOptionGraphicsItem const* option, { auto rect = actualRect(); - QPen pen(QColor(00, 00, 00, 250), 1.0f); + QPen pen(ntColor(Tokens::NodeBorder, 250), 1.0f); painter->setPen(pen); // background @@ -639,10 +774,10 @@ void Port::paint(QPainter* painter, QStyleOptionGraphicsItem const* option, // bgPath.addRoundedRect(-_radius, _radius, rect.width(), rect.height(), // rect.width() / 2, rect.height() / 2); bgPath.addRoundedRect(rect, _radius, _radius); - painter->fillPath(bgPath, QBrush(QColor(170, 170, 170, 255))); + painter->fillPath(bgPath, QBrush(ntColor(Tokens::SocketFill))); // draw border - painter->setPen(QPen(QColor(0, 0, 0), 3)); + painter->setPen(QPen(ntColor(Tokens::NodeBorder), 3)); painter->drawRoundedRect(rect, rect.width() / 2, rect.height() / 2); } @@ -661,8 +796,8 @@ Connection::Connection() connectState = ConnectionState::Complete; - auto pen = QPen(QColor(200, 200, 200)); - pen.setBrush(QColor(50, 150, 250)); + auto pen = QPen(ntColor(Tokens::Wire)); + pen.setBrush(ntColor(Tokens::WireSelected)); pen.setCapStyle(Qt::RoundCap); pen.setWidth(lineThickness); setPen(pen); @@ -676,8 +811,8 @@ void Connection::updatePosFromPorts() void Connection::updatePathFromPositions() { - p = new QPainterPath; - p->moveTo(pos1); + p = QPainterPath(); + p.moveTo(pos1); qreal dx = pos2.x() - pos1.x(); qreal dy = pos2.y() - pos1.y(); @@ -685,10 +820,10 @@ void Connection::updatePathFromPositions() QPointF ctr1(pos1.x() + dx * 0.5, pos1.y()); QPointF ctr2(pos2.x() - dx * 0.5, pos2.y()); - p->cubicTo(ctr1, ctr2, pos2); - p->setFillRule(Qt::OddEvenFill); + p.cubicTo(ctr1, ctr2, pos2); + p.setFillRule(Qt::OddEvenFill); - setPath(*p); + setPath(p); } void Connection::paint(QPainter* painter, @@ -698,27 +833,26 @@ void Connection::paint(QPainter* painter, painter->save(); if (connectState == ConnectionState::Dragging) { - QPen pen(QColor(150, 150, 150), lineThickness); + QPen pen(ntColor(Tokens::WireDragging), lineThickness); pen.setStyle(Qt::DashLine); pen.setDashOffset(4); painter->setPen(pen); - painter->drawPath(*p); + painter->drawPath(p); - painter->setPen(QPen(QColor(0, 0, 0), 3)); - painter->setBrush(QBrush(QColor(150, 150, 150))); + painter->setPen(QPen(ntColor(Tokens::NodeBorder), 3)); + painter->setBrush(QBrush(ntColor(Tokens::WireDragging))); painter->drawEllipse(pos1, 7, 7); painter->setPen(Qt::NoPen); painter->drawEllipse(pos2, 6, 6); } if (connectState == ConnectionState::Complete) { - // create gradient for line - QPen pen(QColor(170, 170, 170), lineThickness); + QPen pen(ntColor(Tokens::Wire), lineThickness); painter->setPen(pen); - painter->drawPath(*p); + painter->drawPath(p); - painter->setPen(QPen(QColor(0, 0, 0), 3)); - painter->setBrush(QBrush(QColor(170, 170, 170))); + painter->setPen(QPen(ntColor(Tokens::NodeBorder), 3)); + painter->setBrush(QBrush(ntColor(Tokens::Wire))); painter->drawEllipse(pos1, 7, 7); painter->drawEllipse(pos2, 7, 7); } diff --git a/src/nodegraph/graph/scene.h b/src/nodegraph/graph/scene.h index 052e05e0..0c20a9f2 100644 --- a/src/nodegraph/graph/scene.h +++ b/src/nodegraph/graph/scene.h @@ -5,9 +5,9 @@ #include #include #include +#include #include #include -#include #include #include #include @@ -33,7 +33,13 @@ typedef QSharedPointer ScenePtr; typedef QSharedPointer${project} - Project Name
${name} - Output Node " "Name
"); - helpLabel->setStyleSheet("QLabel { color: #999; }"); + helpLabel->setObjectName("ExportHelp"); // styled in app.qss.in mainLayout->addWidget(helpLabel); // Spacer @@ -105,18 +105,13 @@ void ExportDialog::setupUI() void ExportDialog::updateDestinationDisplay() { - if (exportDestination.isEmpty()) { - destinationLabel->setText("No destination selected"); - destinationLabel->setStyleSheet("QLabel { padding: 5px; border-radius: " - "3px; color: #999; }"); - chooseDestinationBtn->setText("Choose Folder"); - } - else { - destinationLabel->setText(exportDestination); - destinationLabel->setStyleSheet("QLabel { padding: 5px; border-radius: " - "3px; }"); - chooseDestinationBtn->setText("..."); - } + const bool empty = exportDestination.isEmpty(); + destinationLabel->setText(empty ? "No destination selected" : exportDestination); + // "empty" drives the muted color via app.qss.in (#ExportDestination[empty="true"]) + destinationLabel->setProperty("empty", empty); + destinationLabel->style()->unpolish(destinationLabel); + destinationLabel->style()->polish(destinationLabel); + chooseDestinationBtn->setText(empty ? "Choose Folder" : "..."); } void ExportDialog::onChooseDestination() diff --git a/src/texturelab/widgets/graphwidget.cpp b/src/texturelab/widgets/graphwidget.cpp index b3946925..f4a8b4cd 100644 --- a/src/texturelab/widgets/graphwidget.cpp +++ b/src/texturelab/widgets/graphwidget.cpp @@ -1,4 +1,7 @@ #include "graphwidget.h" +#include "../clipboard.h" +#include "../undo/undocommands.h" +#include #include #include #include @@ -6,12 +9,22 @@ #include #include #include +#include #include #include #include #include #include +#include +class NoWheelComboBox : public QComboBox { +public: + using QComboBox::QComboBox; + void wheelEvent(QWheelEvent* event) override { event->ignore(); } +}; + +#include "../systeminfo.h" +#include "../telemetry.h" #include "./graphics/texturerenderer.h" #include "./models.h" #include "./utils.h" @@ -23,6 +36,26 @@ #include "nodegraph.h" #include "nodesearchpopup.h" +void GraphWidget::syncFrameToScene(const FramePtr& frame) +{ + if (!frame || !scene) + return; + auto ngFrame = scene->getFrameById(frame->id); + if (ngFrame) { + ngFrame->setTitle(frame->text); + ngFrame->setColor(frame->color); + } +} + +void GraphWidget::syncCommentToScene(const CommentPtr& comment) +{ + if (!comment || !scene) + return; + auto ngComment = scene->getCommentById(comment->id); + if (ngComment) + ngComment->setText(comment->text); +} + GraphWidget::GraphWidget() : QMainWindow(nullptr) { graph = new nodegraph::NodeGraph(this); @@ -43,40 +76,41 @@ GraphWidget::GraphWidget() : QMainWindow(nullptr) connect(graph, &nodegraph::NodeGraph::connectionAdded, [=](nodegraph::ConnectionPtr con) { - qDebug() << "CONNECTION ADDED"; - - // auto sceneCon = project->getConnectionById(con->id()); - // sceneCon->rightNode->isDirty = true; - - auto leftNode = - project->getNodeById(con->startPort->node->id()); - auto rightNode = project->getNodeById(con->endPort->node->id()); - auto rightName = con->endPort->name; - - project->addConnection(leftNode, rightNode, rightName); - - // make ready for update - rightNode->isDirty = true; - - // todo: try to update later - renderer->update(); + auto leftNodeId = con->startPort->node->id(); + auto leftOutput = con->startPort->name; + auto rightNodeId = con->endPort->node->id(); + auto rightInput = con->endPort->name; + if (undoStack) + undoStack->push(new AddConnectionCommand( + project, scene, renderer, leftNodeId, leftOutput, + rightNodeId, rightInput)); + else { + // addConnection() marks the right node and everything + // downstream of it dirty + project->addConnection(project->getNodeById(leftNodeId), + project->getNodeById(rightNodeId), + rightInput); + renderer->update(); + } }); connect(graph, &nodegraph::NodeGraph::connectionRemoved, [=](nodegraph::ConnectionPtr con) { - qDebug() << "CONNECTION REMOVED"; - auto leftNodeId = con->startPort->node->id(); + auto leftOutput = con->startPort->name; auto rightNodeId = con->endPort->node->id(); - auto portName = con->endPort->name; - - auto removedCon = project->removeConnection( - leftNodeId, rightNodeId, portName); - - removedCon->rightNode->isDirty = true; - - // todo: try to update later - renderer->update(); + auto rightInput = con->endPort->name; + if (undoStack) + undoStack->push(new RemoveConnectionCommand( + project, scene, renderer, leftNodeId, leftOutput, + rightNodeId, rightInput)); + else { + // removeConnection() marks the right node and everything + // downstream of it dirty + project->removeConnection(leftNodeId, rightNodeId, + rightInput); + renderer->update(); + } }); connect(graph, &nodegraph::NodeGraph::nodeSelectionChanged, @@ -111,13 +145,93 @@ GraphWidget::GraphWidget() : QMainWindow(nullptr) } }); - // connect(graph, &nodegraph::NodeGraph::nodeAdded, - // [=](nodegraph::NodePtr node) { qDebug() << "NODE ADDED"; }); + connect( + graph, &nodegraph::NodeGraph::deleteRequested, + [=](QList nodes, QList frames, + QList comments) { + QList nodeIds, frameIds, commentIds; + for (auto& n : nodes) + nodeIds.append(n->id()); + for (auto& f : frames) + frameIds.append(f->id()); + for (auto& c : comments) + commentIds.append(c->id()); + if (undoStack) + undoStack->push(new DeleteItemsCommand( + project, scene, renderer, nodeIds, frameIds, commentIds)); + else { + // Fallback: direct deletion (no undo) + for (auto& n : nodes) { + scene->removeNode(n); + + // also drops the node's connections and channel + // assignment, marking downstream nodes dirty + project->removeNode(n->id()); + } + for (auto& f : frames) { + scene->removeFrame(f); + project->frames.remove(f->id()); + } + for (auto& c : comments) { + scene->removeComment(c); + project->comments.remove(c->id()); + } + renderer->update(); + } + emit nodeSelectionChanged(TextureNodePtr(nullptr)); + emit frameSelectionChanged(FramePtr(nullptr)); + emit commentSelectionChanged(CommentPtr(nullptr)); + }); + + connect(graph, &nodegraph::NodeGraph::itemsMoveFinished, + [=](QMap oldPos, QMap newPos) { + if (undoStack) + undoStack->push( + new MoveItemsCommand(project, scene, oldPos, newPos)); + }); - // connect(graph, &nodegraph::NodeGraph::nodeRemoved, - // [=](nodegraph::NodePtr node) { qDebug() << "NODE REMOVED"; }); + connect(graph, &nodegraph::NodeGraph::frameSelectionChanged, + [=](nodegraph::FramePtr ngFrame) { + if (!ngFrame || !project) { + emit frameSelectionChanged(FramePtr(nullptr)); + return; + } + auto modelFrame = project->frames.value(ngFrame->id()); + emit frameSelectionChanged(modelFrame); + }); + + connect(graph, &nodegraph::NodeGraph::commentSelectionChanged, + [=](nodegraph::CommentPtr ngComment) { + if (!ngComment || !project) { + emit commentSelectionChanged(CommentPtr(nullptr)); + return; + } + auto modelComment = project->comments.value(ngComment->id()); + emit commentSelectionChanged(modelComment); + }); // library = nullptr; + + // Actions rather than plain shortcuts, so the main window's Edit menu can + // reuse them and display the keys. The widget context keeps them off text + // fields in the other docks. + auto makeClipboardAction = [this](const QString& text, + QKeySequence::StandardKey key, + void (GraphWidget::*slot)()) { + auto action = new QAction(text, this); + action->setShortcut(key); + action->setShortcutContext(Qt::WidgetWithChildrenShortcut); + connect(action, &QAction::triggered, this, slot); + this->addAction(action); + return action; + }; + + cutAction = makeClipboardAction("Cut", QKeySequence::Cut, + &GraphWidget::executeCut); + copyAction = makeClipboardAction("Copy", QKeySequence::Copy, + &GraphWidget::executeCopy); + pasteAction = makeClipboardAction("Paste", QKeySequence::Paste, + &GraphWidget::executePaste); } void GraphWidget::setupToolbar() @@ -127,7 +241,7 @@ void GraphWidget::setupToolbar() toolbar->addWidget(new QLabel("Resolution: ")); - resolutionPicker = new QComboBox(); + resolutionPicker = new NoWheelComboBox(); for (int res : {32, 64, 128, 256, 512, 1024, 2048, 4096}) resolutionPicker->addItem(QString("%1 x %1").arg(res), res); resolutionPicker->setCurrentIndex(5); // default: 1024 @@ -138,7 +252,19 @@ void GraphWidget::setupToolbar() [=](int /*index*/) { if (!project) return; - int res = resolutionPicker->currentData().toInt(); + const int previous = project->textureWidth; + const int res = resolutionPicker->currentData().toInt(); + if (res == previous) + return; + + if (!confirmResolutionChange(previous, res)) { + QSignalBlocker block(resolutionPicker); + const int back = resolutionPicker->findData(previous); + if (back >= 0) + resolutionPicker->setCurrentIndex(back); + return; + } + project->textureWidth = res; project->textureHeight = res; for (auto& node : project->nodes) @@ -161,6 +287,9 @@ void GraphWidget::setupToolbar() connect(seedInput, &QSpinBox::valueChanged, this, [=]() { if (!project) return; + Telemetry::breadcrumb("ui.seed", "random seed changed", + {{"seed", (int64_t)seedInput->value()}, + {"node_count", (int64_t)project->nodes.size()}}); project->randomSeed = seedInput->value(); for (auto& node : project->nodes) node->isDirty = true; @@ -223,6 +352,7 @@ void GraphWidget::setTextureProject(TextureProjectPtr project) auto gframe = nodegraph::Frame::create(); gframe->setId(frame->id); gframe->setTitle(frame->text); + gframe->setColor(frame->color); gframe->setPos(frame->pos.x(), frame->pos.y()); if (frame->size.x() > 0 && frame->size.y() > 0) gframe->setSize(frame->size.x(), frame->size.y()); @@ -248,16 +378,56 @@ void GraphWidget::addNode(const TextureNodePtr& node) scene->addNode(gnode); } +void GraphWidget::syncPositionsToModel() +{ + if (!project || !scene) + return; + + for (auto& node : project->nodes) { + auto gnode = scene->getNodeById(node->id); + if (gnode) { + auto center = gnode->getCenter(); + node->pos = QVector2D(center.x(), center.y()); + } + } + + for (auto& comment : project->comments) { + auto gcomment = scene->getCommentById(comment->id); + if (gcomment) { + auto p = gcomment->pos(); + comment->pos = QVector2D(p.x(), p.y()); + } + } + + for (auto& frame : project->frames) { + auto gframe = scene->getFrameById(frame->id); + if (gframe) { + auto p = gframe->pos(); + frame->pos = QVector2D(p.x(), p.y()); + auto rect = gframe->frameRect(); + frame->size = QVector2D(rect.width(), rect.height()); + } + } +} + void GraphWidget::dragEnterEvent(QDragEnterEvent* evt) { - // qDebug() << "Drag enter"; - evt->acceptProposedAction(); + // Only claim drags we actually handle (nodes/frames/comments dragged + // from the Library panel). Anything else — e.g. a .texture file + // dragged in from the OS — must be left ignored so Qt forwards it up + // to MainWindow's dragEnterEvent instead of it being swallowed here. + if (evt->mimeData()->hasFormat(LIBRARY_ITEM_MIME_FORMAT)) + evt->acceptProposedAction(); + else + evt->ignore(); } void GraphWidget::dragMoveEvent(QDragMoveEvent* evt) { - // qDebug() << "drag move"; - evt->acceptProposedAction(); + if (evt->mimeData()->hasFormat(LIBRARY_ITEM_MIME_FORMAT)) + evt->acceptProposedAction(); + else + evt->ignore(); } void GraphWidget::dropEvent(QDropEvent* evt) @@ -268,31 +438,146 @@ void GraphWidget::dropEvent(QDropEvent* evt) auto scenePos = this->graph->mapToScene(evt->position().toPoint()); if (data->itemType == PopupItemType::Frame) { - auto frame = nodegraph::Frame::create(); - frame->setPos(scenePos); - scene->addFrame(frame); + QString frameId = + QUuid::createUuid().toString(QUuid::WithoutBraces); + if (undoStack) + undoStack->push(new AddFrameCommand(project, scene, frameId, + QVector2D(scenePos))); + else { + auto frame = nodegraph::Frame::create(); + frame->setPos(scenePos); + scene->addFrame(frame); + if (project) { + auto modelFrame = FramePtr(new Frame()); + modelFrame->id = frame->id(); + modelFrame->pos = QVector2D(scenePos); + project->frames[modelFrame->id] = modelFrame; + } + } } else if (data->itemType == PopupItemType::Comment) { - auto comment = nodegraph::Comment::create(); - comment->setPos(scenePos); - scene->addComment(comment); + QString commentId = + QUuid::createUuid().toString(QUuid::WithoutBraces); + if (undoStack) + undoStack->push(new AddCommentCommand(project, scene, commentId, + QVector2D(scenePos))); + else { + auto comment = nodegraph::Comment::create(); + comment->setPos(scenePos); + scene->addComment(comment); + if (project) { + auto modelComment = CommentPtr(new Comment()); + modelComment->id = comment->id(); + modelComment->pos = QVector2D(scenePos); + project->comments[modelComment->id] = modelComment; + } + } } else { - auto node = project->library->createNode(data->libraryItemName); - node->pos = QVector2D(scenePos); - this->project->addNode(node); - this->addNode(node); - this->renderer->update(); + if (undoStack) + undoStack->push(new AddNodeCommand(project, scene, renderer, + data->libraryItemName, + QVector2D(scenePos))); + else { + auto node = project->library->createNode(data->libraryItemName); + node->pos = QVector2D(scenePos); + project->addNode(node); + addNode(node); + renderer->update(); + } } evt->accept(); } + else { + evt->ignore(); + } +} + +bool GraphWidget::confirmResolutionChange(int from, int to) +{ + const int nodeCount = project ? project->nodes.size() : 0; + const int64_t estimated = + TextureRenderer::estimatedNodeTextureBytes(to) * nodeCount; + const SystemInfo::GpuMemory mem = + renderer ? renderer->queryGpuMemory() : SystemInfo::GpuMemory{}; + + Telemetry::breadcrumb( + "ui.resolution", + std::to_string(from) + " -> " + std::to_string(to), + {{"from", (int64_t)from}, + {"to", (int64_t)to}, + {"node_count", (int64_t)nodeCount}, + {"estimated_mb", estimated / (1024 * 1024)}, + {"vram_available_mb", + mem.known ? mem.availableKb / 1024 : (int64_t)-1}}); + + // Only worth asking when the driver actually reports free VRAM and we're + // clearly over it. Where it doesn't (plenty of Mesa configurations), the + // rollback in TextureRenderer covers us instead of nagging on a guess. + const int64_t availableBytes = mem.availableKb * 1024; + if (!mem.known || estimated <= availableBytes * 7 / 10) + return true; + + const auto mb = [](int64_t bytes) { + return QString::number(bytes / (1024 * 1024)); + }; + + const auto choice = QMessageBox::warning( + this, tr("Not enough video memory?"), + tr("Rendering %1 nodes at %2 x %2 needs about %3 MB of video memory, " + "but your GPU reports only %4 MB free.\n\n" + "TextureLab will fall back to %5 x %5 if it runs out. Continue?") + .arg(nodeCount) + .arg(to) + .arg(mb(estimated)) + .arg(mb(availableBytes)) + .arg(from), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + + if (choice != QMessageBox::Yes) { + Telemetry::breadcrumb("ui.resolution", "resolution change declined by user", + {{"to", (int64_t)to}}); + return false; + } + + return true; +} + +void GraphWidget::onResolutionChangeFailed(int requested, int fallback) +{ + { + QSignalBlocker block(resolutionPicker); + const int index = resolutionPicker->findData(fallback); + if (index >= 0) + resolutionPicker->setCurrentIndex(index); + } + + QMessageBox::warning( + this, tr("Resolution change failed"), + tr("Your GPU ran out of memory allocating textures at %1 x %1.\n\n" + "The project has been returned to %2 x %2.") + .arg(requested) + .arg(fallback)); } void GraphWidget::setTextureRenderer(TextureRenderer* renderer) { this->renderer = renderer; + // MainWindow clears the renderer with a null before building the next one + // (setProject); connecting to it emits "invalid nullptr parameter" for + // every signal below. + if (!renderer) + return; + + // Queued: rollBackResolution() emits this from inside TextureRenderer's + // update loop, and onResolutionChangeFailed puts up a modal dialog. Letting + // that spin an event loop mid-update would re-enter update() through the + // renderer's queued nodeRendered callbacks. + connect(renderer, &TextureRenderer::resolutionChangeFailed, this, + &GraphWidget::onResolutionChangeFailed, Qt::QueuedConnection); + connect(renderer, &TextureRenderer::thumbnailGenerated, [=](const QString& nodeId, GLint texId, const QPixmap& pixmap) { // scene->setNodeThumbnail(nodeId, pixmap); @@ -317,6 +602,169 @@ void GraphWidget::keyPressEvent(QKeyEvent* event) } } +void GraphWidget::executeCopy() +{ + if (!project || !scene) + return; + + syncPositionsToModel(); + + QList nodeIds, frameIds, commentIds; + for (auto item : scene->selectedItems()) { + if (item->type() == (int)nodegraph::SceneItemType::Node) { + auto node = qgraphicsitem_cast(item); + if (node) + nodeIds.append(node->id()); + } + else if (item->type() == (int)nodegraph::SceneItemType::Frame) { + auto frame = qgraphicsitem_cast(item); + if (frame) + frameIds.append(frame->id()); + } + else if (item->type() == (int)nodegraph::SceneItemType::Comment) { + auto comment = qgraphicsitem_cast(item); + if (comment) + commentIds.append(comment->id()); + } + } + + if (nodeIds.isEmpty() && frameIds.isEmpty() && commentIds.isEmpty()) + return; + + Clipboard::copyItems(project, nodeIds, frameIds, commentIds); +} + +void GraphWidget::executeCut() +{ + if (!project || !scene) + return; + + executeCopy(); + + QList nodeIds, frameIds, commentIds; + for (auto item : scene->selectedItems()) { + if (item->type() == (int)nodegraph::SceneItemType::Node) { + auto node = qgraphicsitem_cast(item); + if (node) + nodeIds.append(node->id()); + } + else if (item->type() == (int)nodegraph::SceneItemType::Frame) { + auto frame = qgraphicsitem_cast(item); + if (frame) + frameIds.append(frame->id()); + } + else if (item->type() == (int)nodegraph::SceneItemType::Comment) { + auto comment = qgraphicsitem_cast(item); + if (comment) + commentIds.append(comment->id()); + } + } + + if (nodeIds.isEmpty() && frameIds.isEmpty() && commentIds.isEmpty()) + return; + + if (undoStack) + undoStack->push(new DeleteItemsCommand(project, scene, renderer, + nodeIds, frameIds, commentIds)); + else { + for (const auto& id : nodeIds) { + auto ngNode = scene->getNodeById(id); + if (ngNode) + scene->removeNode(ngNode); + // also drops the node's connections and channel assignment, + // marking downstream nodes dirty + project->removeNode(id); + } + for (const auto& id : frameIds) { + auto f = scene->getFrameById(id); + if (f) + scene->removeFrame(f); + project->frames.remove(id); + } + for (const auto& id : commentIds) { + auto c = scene->getCommentById(id); + if (c) + scene->removeComment(c); + project->comments.remove(id); + } + if (renderer) + renderer->update(); + } + + emit nodeSelectionChanged(TextureNodePtr(nullptr)); + emit frameSelectionChanged(FramePtr(nullptr)); + emit commentSelectionChanged(CommentPtr(nullptr)); +} + +void GraphWidget::executePaste() +{ + if (!project || !scene) + return; + + QPointF viewCenter = graph->mapToScene(graph->viewport()->rect().center()); + + if (undoStack) { + auto* cmd = new PasteCommand(project, scene, renderer, viewCenter); + if (cmd->isEmpty()) { + delete cmd; + return; + } + undoStack->push(cmd); + } + else { + QList newNodes; + QList newConnections; + QList newComments; + QList newFrames; + + if (!Clipboard::pasteItems(project, viewCenter, newNodes, + newConnections, newComments, newFrames)) + return; + + scene->clearSelection(); + for (auto& node : newNodes) { + project->nodes[node->id] = node; + addNode(node); + auto ngNode = scene->getNodeById(node->id); + if (ngNode) + ngNode->setSelected(true); + } + for (auto& con : newConnections) { + project->connections[con->id] = con; + project->markNodeAsDirty(con->rightNode); + auto l = scene->getNodeById(con->leftNode->id); + auto r = scene->getNodeById(con->rightNode->id); + if (l && r) + scene->connectNodes(l, "output", r, con->rightNodeInputName); + } + for (auto& comment : newComments) { + project->comments[comment->id] = comment; + auto gc = nodegraph::Comment::create(); + gc->setId(comment->id); + gc->setText(comment->text); + gc->setPos(comment->pos.x(), comment->pos.y()); + scene->addComment(gc); + gc->setSelected(true); + } + for (auto& frame : newFrames) { + project->frames[frame->id] = frame; + auto gf = nodegraph::Frame::create(); + gf->setId(frame->id); + gf->setTitle(frame->text); + gf->setColor(frame->color); + gf->setPos(frame->pos.x(), frame->pos.y()); + if (frame->size.x() > 0 && frame->size.y() > 0) + gf->setSize(frame->size.x(), frame->size.y()); + scene->addFrame(gf); + gf->setSelected(true); + } + if (renderer) + renderer->update(); + } +} + +void GraphWidget::setUndoStack(QUndoStack* stack) { undoStack = stack; } + void GraphWidget::addItemFromSearch(const QString& name, PopupItemType type, const QPoint& position) { @@ -324,26 +772,53 @@ void GraphWidget::addItemFromSearch(const QString& name, PopupItemType type, auto scenePos = graph->mapToScene(localPos); if (type == PopupItemType::Frame) { - auto frame = nodegraph::Frame::create(); - frame->setPos(scenePos); - scene->addFrame(frame); + QString frameId = QUuid::createUuid().toString(QUuid::WithoutBraces); + if (undoStack) + undoStack->push(new AddFrameCommand(project, scene, frameId, + QVector2D(scenePos))); + else { + auto frame = nodegraph::Frame::create(); + frame->setPos(scenePos); + scene->addFrame(frame); + if (project) { + auto modelFrame = FramePtr(new Frame()); + modelFrame->id = frame->id(); + modelFrame->pos = QVector2D(scenePos); + project->frames[modelFrame->id] = modelFrame; + } + } } else if (type == PopupItemType::Comment) { - auto comment = nodegraph::Comment::create(); - comment->setPos(scenePos); - scene->addComment(comment); + QString commentId = QUuid::createUuid().toString(QUuid::WithoutBraces); + if (undoStack) + undoStack->push(new AddCommentCommand(project, scene, commentId, + QVector2D(scenePos))); + else { + auto comment = nodegraph::Comment::create(); + comment->setPos(scenePos); + scene->addComment(comment); + if (project) { + auto modelComment = CommentPtr(new Comment()); + modelComment->id = comment->id(); + modelComment->pos = QVector2D(scenePos); + project->comments[modelComment->id] = modelComment; + } + } } else { if (!project || !project->library) return; - auto node = project->library->createNode(name); - node->pos = QVector2D(scenePos); - this->project->addNode(node); - this->addNode(node); - - if (this->renderer) { - this->renderer->update(); + if (undoStack) + undoStack->push(new AddNodeCommand(project, scene, renderer, name, + QVector2D(scenePos))); + else { + auto node = project->library->createNode(name); + node->pos = QVector2D(scenePos); + project->addNode(node); + addNode(node); + if (renderer) + renderer->update(); } } } \ No newline at end of file diff --git a/src/texturelab/widgets/graphwidget.h b/src/texturelab/widgets/graphwidget.h index 597fa034..10fa3313 100644 --- a/src/texturelab/widgets/graphwidget.h +++ b/src/texturelab/widgets/graphwidget.h @@ -5,7 +5,9 @@ #include #include #include +#include +class QAction; class QDragEnterEvent; class TextureRenderer; @@ -19,8 +21,12 @@ class Library; class TextureProject; class TextureNode; +class Comment; +class Frame; typedef QSharedPointer TextureProjectPtr; typedef QSharedPointer TextureNodePtr; +typedef QSharedPointer CommentPtr; +typedef QSharedPointer FramePtr; class GraphWidget : public QMainWindow { Q_OBJECT @@ -29,6 +35,7 @@ class GraphWidget : public QMainWindow { GraphWidget(); void setTextureProject(TextureProjectPtr project); + void setUndoStack(QUndoStack* stack); void dragEnterEvent(QDragEnterEvent* evt); void dragMoveEvent(QDragMoveEvent* event); @@ -36,6 +43,10 @@ class GraphWidget : public QMainWindow { void keyPressEvent(QKeyEvent* event) override; void setTextureRenderer(TextureRenderer* renderer); + void syncPositionsToModel(); + + void syncFrameToScene(const FramePtr& frame); + void syncCommentToScene(const CommentPtr& comment); nodegraph::NodeGraph* graph; // Library* library; @@ -43,6 +54,15 @@ class GraphWidget : public QMainWindow { TextureProjectPtr project; TextureRenderer* renderer; + QUndoStack* undoStack = nullptr; + + // Clipboard actions. These own the Cut/Copy/Paste shortcuts, scoped to + // this widget so line edits elsewhere in the window keep their own, and + // are reused by the main window's Edit menu so it shows the same keys + // without registering a second, ambiguous binding. + QAction* cutAction; + QAction* copyAction; + QAction* pasteAction; protected: void addNode(const TextureNodePtr& node); @@ -52,13 +72,29 @@ class GraphWidget : public QMainWindow { private: void setupToolbar(); + // Breadcrumbs the change, and — when the driver tells us how much VRAM is + // free — asks first if the new resolution plausibly won't fit. Returns + // false if the user backed out. + bool confirmResolutionChange(int from, int to); + + // Puts the picker back and explains, after TextureRenderer gave up on a + // resolution and rolled the project back. + void onResolutionChangeFailed(int requested, int fallback); + NodeSearchPopup* searchPopup; QPoint lastMousePos; QComboBox* resolutionPicker; QSpinBox* seedInput; +public slots: + void executeCopy(); + void executeCut(); + void executePaste(); + signals: void nodeSelectionChanged(const TextureNodePtr& node); void nodeDoubleClicked(const TextureNodePtr& node); + void frameSelectionChanged(const FramePtr& frame); + void commentSelectionChanged(const CommentPtr& comment); }; \ No newline at end of file diff --git a/src/texturelab/widgets/librarywidget.cpp b/src/texturelab/widgets/librarywidget.cpp index 8a86e87f..6d643236 100644 --- a/src/texturelab/widgets/librarywidget.cpp +++ b/src/texturelab/widgets/librarywidget.cpp @@ -3,13 +3,17 @@ #include "./libraries/library.h" #include +#include +#include #include #include #include #include #include +#include #include #include +#include #include // https://doc.qt.io/qt-6/qmimedata.html @@ -25,11 +29,33 @@ bool LibraryItemMimeData::hasFormat(const QString& format) const LibraryWidget::LibraryWidget() : QWidget() { + this->setObjectName("LibraryPanel"); // QSS scoping (app.qss.in) this->setMinimumWidth(100); this->setLayout(new QVBoxLayout()); + // library version indicator + upgrade button + auto versionRow = new QWidget(this); + auto versionLayout = new QHBoxLayout(versionRow); + versionLayout->setContentsMargins(0, 0, 0, 0); + + versionLabel = new QLabel(versionRow); + versionLabel->setObjectName("LibraryVersionLabel"); // styled in app.qss.in + versionLayout->addWidget(versionLabel); + + versionLayout->addStretch(); + + upgradeButton = new QPushButton("Upgrade", versionRow); + upgradeButton->setProperty("variant", "primary"); // draw attention to the action + upgradeButton->setVisible(false); + connect(upgradeButton, &QPushButton::clicked, + this, &LibraryWidget::upgradeRequested); + versionLayout->addWidget(upgradeButton); + + this->layout()->addWidget(versionRow); + // search box searchBar = new QLineEdit(this); + searchBar->setObjectName("LibrarySearch"); searchBar->setPlaceholderText("search"); searchBar->setAlignment(Qt::AlignLeft); connect(searchBar, &QLineEdit::textChanged, @@ -45,6 +71,21 @@ LibraryWidget::LibraryWidget() : QWidget() this->setLibrary(nullptr); } +void LibraryWidget::setLibraryVersion(const QString& version, bool isCurrent) +{ + versionLabel->setText(isCurrent + ? QString("Library: %1").arg(version) + : QString("Library: %1 (outdated)").arg(version)); + + // Drive the color from a dynamic property so the "outdated" tint lives in + // app.qss.in (uses the theme's warn token) rather than a hardcoded hex. + versionLabel->setProperty("outdated", !isCurrent); + versionLabel->style()->unpolish(versionLabel); + versionLabel->style()->polish(versionLabel); + + upgradeButton->setVisible(!isCurrent); +} + void LibraryWidget::addSpecialItem(const QString& name, const QString& iconPath, PopupItemType type) { @@ -124,10 +165,7 @@ LibraryListWidget::LibraryListWidget() : QListWidget() // setAcceptDrops(true); setDropIndicatorShown(true); - setStyleSheet( - "QListView::item{ border-radius: 2px; border: 0px solid rgba(0,0,0,1); " - "margin-left: 6px; }" - "QListView::item:hover{border: 1px solid rgba(50,150,250,1); }"); + setObjectName("LibraryList"); // item styling in app.qss.in } void LibraryListWidget::resizeEvent(QResizeEvent* event) diff --git a/src/texturelab/widgets/librarywidget.h b/src/texturelab/widgets/librarywidget.h index a74c1199..2571ad23 100644 --- a/src/texturelab/widgets/librarywidget.h +++ b/src/texturelab/widgets/librarywidget.h @@ -8,6 +8,8 @@ class Library; class LibraryListWidget; class QLineEdit; +class QLabel; +class QPushButton; // https://stackoverflow.com/questions/37331270/how-to-create-grid-style-qlistwidget class LibraryWidget : public QWidget { @@ -16,15 +18,26 @@ class LibraryWidget : public QWidget { LibraryWidget(); void setLibrary(Library* lib); + // Shows which library version the open project is on. When + // `isCurrent` is false, an "Upgrade" button is shown that emits + // upgradeRequested(). + void setLibraryVersion(const QString& version, bool isCurrent); + LibraryListWidget* listWidget; QLineEdit* searchBar; +signals: + void upgradeRequested(); + private slots: void filterList(const QString& text); private: void addSpecialItem(const QString& name, const QString& iconPath, PopupItemType type); + + QLabel* versionLabel; + QPushButton* upgradeButton; }; class LibraryItemMimeData : public QMimeData { diff --git a/src/texturelab/widgets/nodesearchpopup.cpp b/src/texturelab/widgets/nodesearchpopup.cpp index 48bff934..b7907809 100644 --- a/src/texturelab/widgets/nodesearchpopup.cpp +++ b/src/texturelab/widgets/nodesearchpopup.cpp @@ -13,10 +13,9 @@ NodeSearchPopup::NodeSearchPopup(QWidget* parent) : QFrame(parent) { library = nullptr; - // Setup frame styling for a floating popup + // Setup frame styling for a floating popup (see #NodeSearchPopup in app.qss.in) + setObjectName("NodeSearchPopup"); setWindowFlags(Qt::Popup | Qt::FramelessWindowHint); - setFrameStyle(QFrame::StyledPanel | QFrame::Raised); - setLineWidth(2); // Set fixed size for the popup setFixedSize(300, 400); diff --git a/src/texturelab/widgets/properties/accordionwidget.cpp b/src/texturelab/widgets/properties/accordionwidget.cpp new file mode 100644 index 00000000..1609299d --- /dev/null +++ b/src/texturelab/widgets/properties/accordionwidget.cpp @@ -0,0 +1,47 @@ +#include "accordionwidget.h" + +#include +#include + +AccordionWidget::AccordionWidget(const QString& title, bool startCollapsed, + QWidget* parent) + : QWidget(parent), _title(title), _collapsed(startCollapsed) +{ + auto* outerLayout = new QVBoxLayout(this); + outerLayout->setContentsMargins(0, 0, 0, 2); + outerLayout->setSpacing(0); + this->setLayout(outerLayout); + + headerButton = new QPushButton(this); + headerButton->setObjectName("AccordionHeader"); // styled in app.qss.in + headerButton->setFlat(true); + headerButton->setCursor(Qt::PointingHandCursor); + outerLayout->addWidget(headerButton); + + contentWidget = new QWidget(this); + contentLayout = new QVBoxLayout(contentWidget); + contentLayout->setContentsMargins(0, 0, 0, 0); + contentLayout->setSpacing(0); + contentWidget->setLayout(contentLayout); + outerLayout->addWidget(contentWidget); + + updateHeader(); + contentWidget->setVisible(!_collapsed); + + connect(headerButton, &QPushButton::clicked, this, [this]() { + _collapsed = !_collapsed; + updateHeader(); + contentWidget->setVisible(!_collapsed); + }); +} + +void AccordionWidget::updateHeader() +{ + QString arrow = _collapsed ? " ▶ " : " ▼ "; + headerButton->setText(arrow + _title); +} + +void AccordionWidget::addWidget(QWidget* widget) +{ + contentLayout->addWidget(widget); +} diff --git a/src/texturelab/widgets/properties/accordionwidget.h b/src/texturelab/widgets/properties/accordionwidget.h new file mode 100644 index 00000000..1455094f --- /dev/null +++ b/src/texturelab/widgets/properties/accordionwidget.h @@ -0,0 +1,22 @@ +#pragma once +#include + +class QVBoxLayout; +class QPushButton; + +class AccordionWidget : public QWidget { + Q_OBJECT + + QString _title; + QPushButton* headerButton; + QWidget* contentWidget; + QVBoxLayout* contentLayout; + bool _collapsed; + + void updateHeader(); + +public: + AccordionWidget(const QString& title, bool startCollapsed = false, + QWidget* parent = nullptr); + void addWidget(QWidget* widget); +}; diff --git a/src/texturelab/widgets/properties/curvepropwidget.cpp b/src/texturelab/widgets/properties/curvepropwidget.cpp new file mode 100644 index 00000000..af8e8cef --- /dev/null +++ b/src/texturelab/widgets/properties/curvepropwidget.cpp @@ -0,0 +1,512 @@ +#include "curvepropwidget.h" + +#include "thememanager.h" +#include "tokens.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +// ============================================================================ +// Metrics (colours are theme-driven; see CurveCanvas::refreshColors) +// ============================================================================ + +static constexpr int CANVAS_PAD = 8; // px padding inside canvas +static constexpr float ANCHOR_R = 5.0f; +static constexpr float ANCHOR_R_HL = 6.0f; +static constexpr float HANDLE_R = 3.0f; + +// ============================================================================ +// CurveCanvas +// ============================================================================ + +CurveCanvas::CurveCanvas(QWidget* parent) : QWidget(parent) +{ + setMouseTracking(true); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + setMinimumSize(200, 200); + // Keep square + QSizePolicy sp = sizePolicy(); + sp.setHeightForWidth(true); + setSizePolicy(sp); + + refreshColors(); + connect(&ThemeManager::instance(), &ThemeManager::themeChanged, this, [this]() { + refreshColors(); + update(); + }); +} + +void CurveCanvas::refreshColors() +{ + const Theme& t = ThemeManager::instance().theme(); + colBg = t.color(Tokens::CurveBg); + colGrid = t.color(Tokens::CurveGrid); + colIdentity = t.color(Tokens::CurveIdentity); + colCurve = t.color(Tokens::CurveLine); + colAnchorDef = t.color(Tokens::CurveAnchor); + colAnchorHov = t.color(Tokens::CurveAnchorHover); + colAnchorSel = t.color(Tokens::CurveAnchorSelect); + colHandleLine = t.color(Tokens::CurveHandleLine); + colHandleDot = t.color(Tokens::CurveHandleDot); + colHandleHov = t.color(Tokens::CurveHandleHover); + colHandleCor = t.color(Tokens::CurveHandleCorner); +} + +int CurveCanvas::heightForWidth(int w) const { return w; } +bool CurveCanvas::hasHeightForWidth() const { return true; } + +void CurveCanvas::setCurve(const Curve& c) +{ + curve = c; + update(); +} + +// --------------------------------------------------------------------------- +// Coordinate helpers +// --------------------------------------------------------------------------- + +QPointF CurveCanvas::toWidget(float x, float y) const +{ + int pad = CANVAS_PAD; + float W = width() - 2 * pad; + float H = height() - 2 * pad; + // y is flipped: y=0 → bottom, y=1 → top + return QPointF(pad + x * W, pad + (1.0f - y) * H); +} + +QPointF CurveCanvas::toCurveSpace(QPointF p) const +{ + int pad = CANVAS_PAD; + float W = width() - 2 * pad; + float H = height() - 2 * pad; + float x = (p.x() - pad) / W; + float y = 1.0f - (p.y() - pad) / H; + return QPointF(qBound(0.0, x, 1.0), qBound(0.0, y, 1.0)); +} + +// --------------------------------------------------------------------------- +// Hit testing +// --------------------------------------------------------------------------- + +int CurveCanvas::hitTestAnchor(QPointF pos, float radiusPx) const +{ + for (int i = 0; i < curve.points.size(); i++) { + QPointF wp = toWidget(curve.points[i].x, curve.points[i].y); + if (QLineF(pos, wp).length() <= radiusPx) + return i; + } + return -1; +} + +int CurveCanvas::hitTestHandle(QPointF pos, bool& outLeft, float radiusPx) const +{ + if (selectedPoint < 0 || selectedPoint >= curve.points.size()) + return -1; + + const CurvePoint& pt = curve.points[selectedPoint]; + + QPointF lhW = toWidget(pt.x + pt.lx, pt.y + pt.ly); + QPointF rhW = toWidget(pt.x + pt.rx, pt.y + pt.ry); + + if (QLineF(pos, lhW).length() <= radiusPx) { outLeft = true; return selectedPoint; } + if (QLineF(pos, rhW).length() <= radiusPx) { outLeft = false; return selectedPoint; } + return -1; +} + +int CurveCanvas::hitTestCurvePath(QPointF pos, float tolerancePx) const +{ + if (curve.points.size() < 2) return -1; + + // Sample the curve path and find closest point + const int STEPS = 200; + float minDist = tolerancePx; + int bestSeg = -1; + float bestX = 0.0f; + + for (int i = 0; i < curve.points.size() - 1; i++) { + const CurvePoint& a = curve.points[i]; + const CurvePoint& b = curve.points[i + 1]; + + for (int s = 0; s <= STEPS; s++) { + float t = s / (float)STEPS; + float mt = 1.0f - t; + + float px = mt*mt*mt*a.x + 3*mt*mt*t*(a.x+a.rx) + + 3*mt*t*t*(b.x+b.lx) + t*t*t*b.x; + float py = mt*mt*mt*a.y + 3*mt*mt*t*(a.y+a.ry) + + 3*mt*t*t*(b.y+b.ly) + t*t*t*b.y; + + QPointF wp = toWidget(px, py); + float d = (float)QLineF(pos, wp).length(); + if (d < minDist) { + minDist = d; + bestSeg = i; + bestX = px; + } + } + } + (void)bestX; + return bestSeg; // segment index — caller inserts a point at mouse x +} + +// --------------------------------------------------------------------------- +// Paint +// --------------------------------------------------------------------------- + +void CurveCanvas::paintEvent(QPaintEvent*) +{ + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, true); + + drawGrid(p); + drawIdentityLine(p); + drawCurvePath(p); + drawHandles(p); + drawAnchors(p); +} + +void CurveCanvas::drawGrid(QPainter& p) +{ + p.fillRect(rect(), colBg); + + QPen pen(colGrid, 1); + p.setPen(pen); + + for (int i = 0; i <= 4; i++) { + float t = i / 4.0f; + QPointF a = toWidget(t, 0.0f); + QPointF b = toWidget(t, 1.0f); + p.drawLine(a, b); + + a = toWidget(0.0f, t); + b = toWidget(1.0f, t); + p.drawLine(a, b); + } +} + +void CurveCanvas::drawIdentityLine(QPainter& p) +{ + QPen pen(colIdentity, 1, Qt::DashLine); + p.setPen(pen); + p.drawLine(toWidget(0, 0), toWidget(1, 1)); +} + +void CurveCanvas::drawCurvePath(QPainter& p) +{ + if (curve.points.size() < 2) return; + + QPainterPath path; + path.moveTo(toWidget(curve.points[0].x, curve.points[0].y)); + + for (int i = 0; i < curve.points.size() - 1; i++) { + const CurvePoint& a = curve.points[i]; + const CurvePoint& b = curve.points[i + 1]; + + QPointF cp1 = toWidget(a.x + a.rx, a.y + a.ry); + QPointF cp2 = toWidget(b.x + b.lx, b.y + b.ly); + QPointF end = toWidget(b.x, b.y); + path.cubicTo(cp1, cp2, end); + } + + QPen pen(colCurve, 1.5f); + p.setPen(pen); + p.setBrush(Qt::NoBrush); + p.drawPath(path); +} + +void CurveCanvas::drawHandles(QPainter& p) +{ + if (selectedPoint < 0 || selectedPoint >= curve.points.size()) return; + + const CurvePoint& pt = curve.points[selectedPoint]; + QPointF anchor = toWidget(pt.x, pt.y); + QPointF lh = toWidget(pt.x + pt.lx, pt.y + pt.ly); + QPointF rh = toWidget(pt.x + pt.rx, pt.y + pt.ry); + + QColor dotColor = pt.smooth ? colHandleDot : colHandleCor; + + // Lines from anchor to handles + QPen linePen(colHandleLine, 1); + p.setPen(linePen); + p.drawLine(anchor, lh); + p.drawLine(anchor, rh); + + // Handle dots + auto drawHandle = [&](QPointF pos, bool isHovered) { + QColor c = isHovered ? colHandleHov : dotColor; + p.setPen(QPen(c, 1)); + p.setBrush(Qt::NoBrush); + p.drawEllipse(pos, HANDLE_R, HANDLE_R); + }; + + bool lhHovered = (hoveredHandle == selectedPoint && hoveredHandleLeft); + bool rhHovered = (hoveredHandle == selectedPoint && !hoveredHandleLeft); + drawHandle(lh, lhHovered); + drawHandle(rh, rhHovered); +} + +void CurveCanvas::drawAnchors(QPainter& p) +{ + for (int i = 0; i < curve.points.size(); i++) { + const CurvePoint& pt = curve.points[i]; + QPointF wp = toWidget(pt.x, pt.y); + + float r; + QColor fill; + + if (i == selectedPoint) { + r = ANCHOR_R_HL; + fill = colAnchorSel; + // ring + p.setPen(QPen(colAnchorSel, 1)); + p.setBrush(Qt::NoBrush); + p.drawEllipse(wp, r + 2, r + 2); + } else if (i == hoveredPoint) { + r = ANCHOR_R_HL; + fill = colAnchorHov; + } else { + r = ANCHOR_R; + fill = colAnchorDef; + } + + p.setPen(Qt::NoPen); + p.setBrush(fill); + p.drawEllipse(wp, r, r); + } +} + +// --------------------------------------------------------------------------- +// Mouse events +// --------------------------------------------------------------------------- + +void CurveCanvas::mousePressEvent(QMouseEvent* event) +{ + altHeld = event->modifiers() & Qt::AltModifier; + + if (event->button() == Qt::LeftButton) { + QPointF pos = event->position(); + + // 1. Hit test handle (only when a point is selected) + bool handleLeft = false; + int hi = hitTestHandle(pos, handleLeft); + if (hi >= 0) { + dragTarget = handleLeft ? CurveDragTarget::LeftHandle + : CurveDragTarget::RightHandle; + dragIndex = hi; + dragHandleLeft = handleLeft; + setCursor(Qt::ClosedHandCursor); + return; + } + + // 2. Hit test anchor + int ai = hitTestAnchor(pos); + if (ai >= 0) { + selectedPoint = ai; + dragTarget = CurveDragTarget::Anchor; + dragIndex = ai; + setCursor(Qt::ClosedHandCursor); + update(); + return; + } + + // 3. Hit test curve path → add point on curve + QPointF cs = toCurveSpace(pos); + int pathSeg = hitTestCurvePath(pos); + if (pathSeg >= 0) { + // Snap y to existing curve at this x + curve.addPoint((float)cs.x(), (float)cs.y()); + selectedPoint = -1; + // Find the newly inserted point + for (int i = 0; i < curve.points.size(); i++) { + if (qAbs(curve.points[i].x - (float)cs.x()) < 0.01f) { + selectedPoint = i; + break; + } + } + emit curveChanged(curve); + update(); + return; + } + + // 4. Empty area → add new point + if (curve.points.size() < CURVE_MAX_POINTS) { + curve.addPoint((float)cs.x(), (float)cs.y()); + selectedPoint = -1; + for (int i = 0; i < curve.points.size(); i++) { + if (qAbs(curve.points[i].x - (float)cs.x()) < 0.01f) { + selectedPoint = i; + break; + } + } + emit curveChanged(curve); + update(); + } + } else if (event->button() == Qt::LeftButton) { + // Deselect on background click (handled above via fall-through) + selectedPoint = -1; + update(); + } +} + +void CurveCanvas::mouseMoveEvent(QMouseEvent* event) +{ + QPointF pos = event->position(); + altHeld = event->modifiers() & Qt::AltModifier; + + if (dragTarget == CurveDragTarget::Anchor && dragIndex >= 0) { + QPointF cs = toCurveSpace(pos); + curve.moveAnchor(dragIndex, (float)cs.x(), (float)cs.y()); + emit curveChanged(curve); + emit anchorDragging(dragIndex); + update(); + return; + } + + if ((dragTarget == CurveDragTarget::LeftHandle || + dragTarget == CurveDragTarget::RightHandle) && dragIndex >= 0) + { + // Break symmetry if Alt held + if (altHeld) curve.points[dragIndex].smooth = false; + + QPointF cs = toCurveSpace(pos); + const CurvePoint& pt = curve.points[dragIndex]; + float dx = (float)cs.x() - pt.x; + float dy = (float)cs.y() - pt.y; + + bool isLeft = (dragTarget == CurveDragTarget::LeftHandle); + // Compute delta from current handle position + float curHx = isLeft ? pt.lx : pt.rx; + float curHy = isLeft ? pt.ly : pt.ry; + curve.moveHandle(dragIndex, isLeft, dx - curHx, dy - curHy); + emit curveChanged(curve); + update(); + return; + } + + // Hover detection + int prevHovAnchor = hoveredPoint; + int prevHovHandle = hoveredHandle; + hoveredPoint = hitTestAnchor(pos); + bool hl = false; + hoveredHandle = hitTestHandle(pos, hl); + hoveredHandleLeft = hl; + + if (hoveredPoint >= 0 || hoveredHandle >= 0) + setCursor(Qt::SizeAllCursor); + else + setCursor(Qt::CrossCursor); + + if (hoveredPoint != prevHovAnchor || hoveredHandle != prevHovHandle) + update(); +} + +void CurveCanvas::mouseReleaseEvent(QMouseEvent* event) +{ + if (event->button() == Qt::LeftButton) { + if (dragTarget != CurveDragTarget::None) + emit dragEnded(); + dragTarget = CurveDragTarget::None; + dragIndex = -1; + setCursor(Qt::CrossCursor); + } +} + +void CurveCanvas::contextMenuEvent(QContextMenuEvent* event) +{ + int ai = hitTestAnchor(event->pos()); + if (ai < 0) return; + + QMenu menu(this); + QAction* removeAct = menu.addAction("Remove Point"); + removeAct->setEnabled(ai > 0 && ai < curve.points.size() - 1); + + QAction* chosen = menu.exec(event->globalPos()); + if (chosen == removeAct) { + curve.removePoint(ai); + if (selectedPoint == ai) selectedPoint = -1; + else if (selectedPoint > ai) selectedPoint--; + emit curveChanged(curve); + update(); + } +} + +void CurveCanvas::leaveEvent(QEvent*) +{ + hoveredPoint = -1; + hoveredHandle = -1; + update(); +} + +// ============================================================================ +// CurvePropWidget +// ============================================================================ + +CurvePropWidget::CurvePropWidget(CurveProp* prop, QWidget* parent) + : QWidget(parent), prop(prop) +{ + auto* vLayout = new QVBoxLayout(this); + vLayout->setContentsMargins(0, 0, 0, 4); + vLayout->setSpacing(4); + + // Label row with Reset button + auto* headerRow = new QHBoxLayout(); + headerRow->setContentsMargins(0, 0, 0, 0); + + auto* label = new QLabel(prop->displayName, this); + resetBtn = new QPushButton("Reset", this); + resetBtn->setFixedWidth(50); + resetBtn->setFixedHeight(20); + resetBtn->setProperty("size", "small"); // styled in app.qss.in + + headerRow->addWidget(label); + headerRow->addStretch(); + headerRow->addWidget(resetBtn); + vLayout->addLayout(headerRow); + + // Canvas + canvas = new CurveCanvas(this); + canvas->setCurve(prop->value); + vLayout->addWidget(canvas); + + // Readout label + readout = new QLabel(this); + readout->setObjectName("CurveReadout"); // styled in app.qss.in + readout->setVisible(false); + vLayout->addWidget(readout); + + setLayout(vLayout); + + // Connections + connect(canvas, &CurveCanvas::curveChanged, this, [this](const Curve& c) { + this->prop->value = c; + emit valueChanged(c); + }); + + connect(canvas, &CurveCanvas::anchorDragging, this, [this](int idx) { + if (idx >= 0 && idx < this->prop->value.points.size()) { + const CurvePoint& pt = this->prop->value.points[idx]; + readout->setText(QString("In: %1 Out: %2") + .arg(pt.x, 0, 'f', 2) + .arg(pt.y, 0, 'f', 2)); + readout->setVisible(true); + } + }); + + connect(canvas, &CurveCanvas::dragEnded, this, [this]() { + readout->setVisible(false); + }); + + connect(resetBtn, &QPushButton::clicked, this, [this]() { + Curve identity; + this->prop->value = identity; + canvas->setCurve(identity); + readout->setVisible(false); + emit valueChanged(identity); + }); +} diff --git a/src/texturelab/widgets/properties/curvepropwidget.h b/src/texturelab/widgets/properties/curvepropwidget.h new file mode 100644 index 00000000..97a59991 --- /dev/null +++ b/src/texturelab/widgets/properties/curvepropwidget.h @@ -0,0 +1,86 @@ +#pragma once + +#include "../../curve.h" +#include "../../props.h" + +#include +#include +#include +#include + +enum class CurveDragTarget { None, Anchor, LeftHandle, RightHandle }; + +class CurveCanvas : public QWidget { + Q_OBJECT +public: + explicit CurveCanvas(QWidget* parent = nullptr); + + void setCurve(const Curve& curve); + const Curve& getCurve() const { return curve; } + + bool hasHeightForWidth() const override; + int heightForWidth(int w) const override; + +signals: + void curveChanged(const Curve& curve); + void anchorDragging(int index); // emits index while dragging anchor + void dragEnded(); + +protected: + void paintEvent(QPaintEvent* event) override; + void mousePressEvent(QMouseEvent* event) override; + void mouseMoveEvent(QMouseEvent* event) override; + void mouseReleaseEvent(QMouseEvent* event) override; + void contextMenuEvent(QContextMenuEvent* event) override; + void leaveEvent(QEvent* event) override; + +private: + Curve curve; + int selectedPoint = -1; + int hoveredPoint = -1; + int hoveredHandle = -1; // index of point whose handle is hovered + bool hoveredHandleLeft = false; + + CurveDragTarget dragTarget = CurveDragTarget::None; + int dragIndex = -1; + bool dragHandleLeft = false; + QPointF dragStartCurve; // curve-space position at drag start + bool altHeld = false; + + QPointF toWidget(float x, float y) const; + QPointF toCurveSpace(QPointF widgetPos) const; + + int hitTestAnchor(QPointF pos, float radiusPx = 8.0f) const; + // Returns point index; sets outLeft = true if left handle hit + int hitTestHandle(QPointF pos, bool& outLeft, float radiusPx = 8.0f) const; + // Returns point index to insert after if cursor is near the path + int hitTestCurvePath(QPointF pos, float tolerancePx = 6.0f) const; + + void drawGrid(QPainter& p); + void drawIdentityLine(QPainter& p); + void drawCurvePath(QPainter& p); + void drawAnchors(QPainter& p); + void drawHandles(QPainter& p); + + // Theme colors, refreshed from the active theme (surface B: QSS can't reach + // QPainter code). Repopulated on construction and on themeChanged(). + void refreshColors(); + QColor colBg, colGrid, colIdentity, colCurve; + QColor colAnchorDef, colAnchorHov, colAnchorSel; + QColor colHandleLine, colHandleDot, colHandleHov, colHandleCor; +}; + +class CurvePropWidget : public QWidget { + Q_OBJECT +public: + explicit CurvePropWidget(CurveProp* prop, QWidget* parent = nullptr); + +signals: + void valueChanged(const Curve& curve); + +private: + CurveProp* prop; + CurveCanvas* canvas; + QLabel* readout; + QPushButton* resetBtn; +}; diff --git a/src/texturelab/widgets/properties/propertieswidget.cpp b/src/texturelab/widgets/properties/propertieswidget.cpp index 3465d4da..0e6162ac 100644 --- a/src/texturelab/widgets/properties/propertieswidget.cpp +++ b/src/texturelab/widgets/properties/propertieswidget.cpp @@ -1,18 +1,23 @@ #include "propertieswidget.h" #include "../../models.h" #include "../../props.h" +#include "../../undo/undocommands.h" +#include "accordionwidget.h" +#include "curvepropwidget.h" #include "propwidgets.h" #include +#include PropertiesWidget::PropertiesWidget() : QWidget() { + setObjectName("PropertiesPanel"); // for QSS scoping (app.qss.in) displayMode = PropertyDisplayMode::None; textureChannelProp = new EnumProp(); textureChannelProp->displayName = "Texture Channel"; textureChannelProp->values = {"None", "Albedo", "Normal", "Metalness", - "Roughness", "Height", "Alpha"}; + "Roughness", "Height", "Alpha", "AO"}; textureChannelProp->setValue(0); randomSeedProp = new IntProp(); @@ -27,137 +32,202 @@ PropertiesWidget::PropertiesWidget() : QWidget() this->setLayout(layout); } +// Helper: push PropertyChangeCommand if undoStack is set; otherwise apply directly. +// The value is applied before calling this (first-redo pattern). +// The renderer must be passed through: marking a node dirty doesn't render it, +// something has to call TextureRenderer::update() to kick the render loop, and +// on undo/redo there's no propertyUpdated signal to do it. +static void pushPropChange(QUndoStack* stack, TextureNodePtr node, + TextureProjectPtr project, + TextureRenderer* renderer, + const QString& propName, + QVariant oldVal, QVariant newVal) +{ + if (stack) + stack->push(new PropertyChangeCommand( + node, project, renderer, propName, oldVal, newVal)); +} + +QVariant PropertiesWidget::takePropBaseline(Prop* prop, + const QVariant& newValue) +{ + QVariant oldValue = propBaselines.value(prop, newValue); + propBaselines[prop] = newValue; + + return oldValue; +} + +void PropertiesWidget::syncPropBaselines() +{ + for (auto it = propBaselines.begin(); it != propBaselines.end(); ++it) + it.value() = it.key()->getValue(); +} + +QWidget* PropertiesWidget::createPropWidget(Prop* prop, + const TextureNodePtr& node) +{ + propBaselines[prop] = prop->getValue(); + + switch (prop->type) { + case PropType::Float: { + auto widget = new FloatPropWidget(); + widget->setProp((FloatProp*)prop); + propWidgets.append(widget); + connect(widget, &FloatPropWidget::valueChanged, [=](double value) { + QVariant oldVal = takePropBaseline(prop, value); + node->setProp(prop->name, value); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, value); + pushPropChange(undoStack, node, project, renderer, prop->name, oldVal, value); + }); + return widget; + } + case PropType::Bool: { + auto widget = new BoolPropWidget(); + widget->setProp((BoolProp*)prop); + propWidgets.append(widget); + connect(widget, &BoolPropWidget::valueChanged, [=](bool value) { + QVariant oldVal = takePropBaseline(prop, value); + node->setProp(prop->name, value); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, value); + pushPropChange(undoStack, node, project, renderer, prop->name, oldVal, value); + }); + return widget; + } + case PropType::Int: { + auto widget = new IntPropWidget(); + widget->setProp((IntProp*)prop); + propWidgets.append(widget); + connect(widget, &IntPropWidget::valueChanged, [=](long value) { + QVariant oldVal = takePropBaseline(prop, (int)value); + node->setProp(prop->name, (int)value); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, (int)value); + pushPropChange(undoStack, node, project, renderer, prop->name, oldVal, (int)value); + }); + return widget; + } + case PropType::Enum: { + auto widget = new EnumPropWidget(); + widget->setProp((EnumProp*)prop); + propWidgets.append(widget); + connect(widget, &EnumPropWidget::valueChanged, [=](int value) { + QVariant oldVal = takePropBaseline(prop, value); + node->setProp(prop->name, value); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, value); + pushPropChange(undoStack, node, project, renderer, prop->name, oldVal, value); + }); + return widget; + } + case PropType::Color: { + auto widget = new ColorPropWidget(); + widget->setProp((ColorProp*)prop); + propWidgets.append(widget); + connect(widget, &ColorPropWidget::valueChanged, [=](const QColor& value) { + QVariant oldVal = takePropBaseline(prop, value); + node->setProp(prop->name, value); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, value); + pushPropChange(undoStack, node, project, renderer, prop->name, oldVal, value); + }); + return widget; + } + case PropType::Gradient: { + auto widget = new GradientPropWidget(); + widget->setProp((GradientProp*)prop); + propWidgets.append(widget); + connect(widget, &GradientPropWidget::valueChanged, [=](const Gradient& value) { + QVariant newVal = QVariant::fromValue(value); + QVariant oldVal = takePropBaseline(prop, newVal); + node->setProp(prop->name, newVal); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, newVal); + pushPropChange(undoStack, node, project, renderer, prop->name, oldVal, newVal); + }); + return widget; + } + case PropType::Image: { + auto widget = new ImagePropWidget(); + widget->setProp((ImageProp*)prop); + propWidgets.append(widget); + connect(widget, &ImagePropWidget::valueChanged, [=](const QImage& value) { + QVariant oldVal = takePropBaseline(prop, value); + node->setProp(prop->name, value); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, value); + pushPropChange(undoStack, node, project, renderer, prop->name, oldVal, value); + }); + return widget; + } + case PropType::String: { + auto widget = new StringPropWidget(); + widget->setProp((StringProp*)prop); + propWidgets.append(widget); + connect(widget, &StringPropWidget::valueChanged, [=](const QString& value) { + QVariant oldVal = takePropBaseline(prop, value); + node->setProp(prop->name, value); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, value); + pushPropChange(undoStack, node, project, renderer, prop->name, oldVal, value); + }); + return widget; + } + case PropType::Curve: { + auto widget = new CurvePropWidget((CurveProp*)prop); + propWidgets.append(widget); + connect(widget, &CurvePropWidget::valueChanged, [=](const Curve& value) { + QVariant newVal = QVariant::fromValue(value); + QVariant oldVal = takePropBaseline(prop, newVal); + node->setProp(prop->name, newVal); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, newVal); + pushPropChange(undoStack, node, project, renderer, prop->name, oldVal, newVal); + }); + return widget; + } + default: + return nullptr; + } +} + void PropertiesWidget::setSelectedNode(const TextureNodePtr& node) { qDebug() << "Displaying properties for node: " << node->title; - this->selectedNode = node; - // clear current properties + // clear current properties first, then assign (clearSelection resets selectedNode) this->clearSelection(); + this->selectedNode = node; auto layout = (QVBoxLayout*)this->layout(); - // add base props this->addBasePropsToLayout(); - // sort props by order + // sort all props by insertion order QList sortedProps = node->props.values(); std::sort(sortedProps.begin(), sortedProps.end(), [](Prop* a, Prop* b) { return a->order < b->order; }); - // add new props to layout + // ungrouped props first for (auto prop : sortedProps) { - switch (prop->type) { - case PropType::Float: { - auto widget = new FloatPropWidget(); - widget->setProp((FloatProp*)prop); - propWidgets.append(widget); - - connect(widget, &FloatPropWidget::valueChanged, [=](double value) { - // qDebug() << "prop" << prop->name << " changed: " << value; - // set node value - - node->setProp(prop->name, value); - project->markNodeAsDirty(node); - - emit propertyUpdated(prop->name, value); - }); - layout->addWidget(widget); - - } break; - case PropType::Int: { - auto widget = new IntPropWidget(); - widget->setProp((IntProp*)prop); - propWidgets.append(widget); - - connect(widget, &IntPropWidget::valueChanged, [=](int value) { - // qDebug() << "prop" << prop->name << " changed: " << value; - // set node value - - node->setProp(prop->name, value); - project->markNodeAsDirty(node); - - emit propertyUpdated(prop->name, value); - }); - layout->addWidget(widget); - - } break; - case PropType::Enum: { - auto widget = new EnumPropWidget(); - widget->setProp((EnumProp*)prop); - propWidgets.append(widget); - - connect(widget, &EnumPropWidget::valueChanged, [=](int value) { - node->setProp(prop->name, value); - project->markNodeAsDirty(node); - - emit propertyUpdated(prop->name, value); - }); - layout->addWidget(widget); - - } break; - case PropType::Color: { - auto widget = new ColorPropWidget(); - widget->setProp((ColorProp*)prop); - propWidgets.append(widget); - - connect(widget, &ColorPropWidget::valueChanged, - [=](const QColor& value) { - node->setProp(prop->name, value); - project->markNodeAsDirty(node); - - emit propertyUpdated(prop->name, value); - }); - layout->addWidget(widget); - - } break; - case PropType::Gradient: { - auto widget = new GradientPropWidget(); - widget->setProp((GradientProp*)prop); - propWidgets.append(widget); - - connect(widget, &GradientPropWidget::valueChanged, - [=](const Gradient& value) { - node->setProp(prop->name, QVariant::fromValue(value)); - project->markNodeAsDirty(node); - - emit propertyUpdated(prop->name, - QVariant::fromValue(value)); - }); - layout->addWidget(widget); - - } break; - case PropType::Image: { - auto widget = new ImagePropWidget(); - widget->setProp((ImageProp*)prop); - propWidgets.append(widget); - - connect(widget, &ImagePropWidget::valueChanged, - [=](const QImage& value) { - node->setProp(prop->name, value); - project->markNodeAsDirty(node); - - emit propertyUpdated(prop->name, value); - }); - layout->addWidget(widget); - - } break; - case PropType::String: { - auto widget = new StringPropWidget(); - widget->setProp((StringProp*)prop); - propWidgets.append(widget); - - connect(widget, &StringPropWidget::valueChanged, - [=](const QString& value) { - node->setProp(prop->name, value); - project->markNodeAsDirty(node); - - emit propertyUpdated(prop->name, value); - }); + if (prop->group != nullptr) + continue; + auto widget = createPropWidget(prop, node); + if (widget) layout->addWidget(widget); + } - } break; + // then each group as a collapsible accordion + for (auto group : node->propertyGroups) { + auto accordion = + new AccordionWidget(group->name, group->collapsed, this); + for (auto prop : group->props) { + auto widget = createPropWidget(prop, node); + if (widget) + accordion->addWidget(widget); } + layout->addWidget(accordion); } layout->addStretch(1); @@ -195,17 +265,124 @@ void PropertiesWidget::addBasePropsToLayout() auto seedWidget = new IntPropWidget(); seedWidget->setProp(randomSeedProp); connect(seedWidget, &IntPropWidget::valueChanged, [=](int value) { + long oldSeed = this->selectedNode->randomSeed; this->selectedNode->randomSeed = value; this->project->markNodeAsDirty(this->selectedNode); - emit this->propertyUpdated("randomSeed", value); + if (undoStack) + undoStack->push(new RandomSeedChangeCommand( + this->selectedNode, this->project, renderer, oldSeed, value)); }); layout->addWidget(seedWidget); } +void PropertiesWidget::setSelectedFrame(const FramePtr& frame) +{ + this->clearSelection(); + if (!frame) + return; + + this->selectedFrame = frame; + displayMode = PropertyDisplayMode::Frame; + + auto layout = (QVBoxLayout*)this->layout(); + + auto titleLabel = new QLabel("Frame"); + titleLabel->setObjectName("PropSectionTitle"); // styled in app.qss.in + layout->addWidget(titleLabel); + + auto titleProp = new StringProp(); + titleProp->displayName = "Title"; + titleProp->value = frame->text; + auto titleWidget = new StringPropWidget(); + titleWidget->setProp(titleProp); + propWidgets.append(titleWidget); + + connect(titleWidget, &StringPropWidget::valueChanged, [=](const QString& value) { + if (undoStack) { + QString oldTitle = frame->text; + QColor oldColor = frame->color; + frame->text = value; + emit framePropertyChanged(frame); + undoStack->push(new EditFrameCommand( + frame, scene, oldTitle, oldColor, value, oldColor)); + } else { + frame->text = value; + emit framePropertyChanged(frame); + } + }); + layout->addWidget(titleWidget); + + auto colorProp = new ColorProp(); + colorProp->displayName = "Color"; + colorProp->value = frame->color; + auto colorWidget = new ColorPropWidget(); + colorWidget->setProp(colorProp); + propWidgets.append(colorWidget); + + connect(colorWidget, &ColorPropWidget::valueChanged, [=](const QColor& color) { + if (undoStack) { + QString oldTitle = frame->text; + QColor oldColor = frame->color; + frame->color = color; + emit framePropertyChanged(frame); + undoStack->push(new EditFrameCommand( + frame, scene, oldTitle, oldColor, oldTitle, color)); + } else { + frame->color = color; + emit framePropertyChanged(frame); + } + }); + layout->addWidget(colorWidget); + + layout->addStretch(1); +} + +void PropertiesWidget::setSelectedComment(const CommentPtr& comment) +{ + this->clearSelection(); + if (!comment) + return; + + this->selectedComment = comment; + displayMode = PropertyDisplayMode::Comment; + + auto layout = (QVBoxLayout*)this->layout(); + + auto titleLabel = new QLabel("Comment"); + titleLabel->setObjectName("PropSectionTitle"); // styled in app.qss.in + layout->addWidget(titleLabel); + + auto textProp = new StringProp(); + textProp->displayName = "Text"; + textProp->value = comment->text; + auto textWidget = new StringPropWidget(); + textWidget->setProp(textProp); + textWidget->setMultiline(true); + propWidgets.append(textWidget); + + connect(textWidget, &StringPropWidget::valueChanged, [=](const QString& value) { + if (undoStack) { + QString oldText = comment->text; + comment->text = value; + emit commentPropertyChanged(comment); + undoStack->push(new EditCommentCommand(comment, scene, oldText, value)); + } else { + comment->text = value; + emit commentPropertyChanged(comment); + } + }); + layout->addWidget(textWidget); + + layout->addStretch(1); +} + void PropertiesWidget::clearSelection() { displayMode = PropertyDisplayMode::None; + selectedNode.clear(); + selectedFrame.clear(); + selectedComment.clear(); auto layout = this->layout(); @@ -226,9 +403,26 @@ void PropertiesWidget::clearSelection() } propWidgets.clear(); + // the props these pointed at may belong to a node that's going away + propBaselines.clear(); } void PropertiesWidget::setProject(const TextureProjectPtr& project) { this->project = project; +} + +void PropertiesWidget::setScene(NgScenePtr ngScene) +{ + scene = ngScene; +} + +void PropertiesWidget::setUndoStack(QUndoStack* stack) +{ + undoStack = stack; +} + +void PropertiesWidget::setTextureRenderer(TextureRenderer* renderer) +{ + this->renderer = renderer; } \ No newline at end of file diff --git a/src/texturelab/widgets/properties/propertieswidget.h b/src/texturelab/widgets/properties/propertieswidget.h index 518729bd..0cdae997 100644 --- a/src/texturelab/widgets/properties/propertieswidget.h +++ b/src/texturelab/widgets/properties/propertieswidget.h @@ -1,15 +1,28 @@ #pragma once +#include +#include +#include +#include #include #include class TextureProject; class TextureNode; +class Comment; +class Frame; typedef QSharedPointer TextureProjectPtr; typedef QSharedPointer TextureNodePtr; +typedef QSharedPointer CommentPtr; +typedef QSharedPointer FramePtr; +namespace nodegraph { class Scene; } +typedef QSharedPointer NgScenePtr; + +class Prop; class EnumProp; class IntProp; +class TextureRenderer; enum class TextureChannel : int; @@ -23,25 +36,55 @@ class PropertiesWidget : public QWidget { QVector propWidgets; TextureProjectPtr project; + NgScenePtr scene; + QUndoStack* undoStack = nullptr; + // non-owning; needed so undo/redo of a property change can kick the + // render loop (the live edit path goes through propertyUpdated instead) + TextureRenderer* renderer = nullptr; TextureNodePtr selectedNode; + FramePtr selectedFrame; + CommentPtr selectedComment; // base props EnumProp* textureChannelProp; IntProp* randomSeedProp; + // Value each displayed prop held before the edit in progress. The color, + // gradient, image and curve widgets write prop->value themselves before + // emitting valueChanged, so the prop can't be read back for the undo + // baseline — without this their undo steps record oldValue == newValue + // and undoing them changes nothing. + QHash propBaselines; + public: PropertiesWidget(); void setSelectedNode(const TextureNodePtr& node); + void setSelectedFrame(const FramePtr& frame); + void setSelectedComment(const CommentPtr& comment); void clearSelection(); void setProject(const TextureProjectPtr& project); + void setScene(NgScenePtr ngScene); + void setUndoStack(QUndoStack* stack); + void setTextureRenderer(TextureRenderer* renderer); + + // Re-reads the undo baselines from the props. Call after undo/redo, which + // changes prop values behind the panel's back. + void syncPropBaselines(); private: void addBasePropsToLayout(); + QWidget* createPropWidget(Prop* prop, const TextureNodePtr& node); + + // Returns the value the prop held before this edit and records newValue + // as the baseline for the next one. + QVariant takePropBaseline(Prop* prop, const QVariant& newValue); signals: void propertyUpdated(const QString& name, const QVariant& value); void textureChannelUpdated(const TextureChannel& name, const TextureNodePtr& node); + void framePropertyChanged(const FramePtr& frame); + void commentPropertyChanged(const CommentPtr& comment); }; \ No newline at end of file diff --git a/src/texturelab/widgets/properties/propwidgets.cpp b/src/texturelab/widgets/properties/propwidgets.cpp index 16284d98..1a0e3989 100644 --- a/src/texturelab/widgets/properties/propwidgets.cpp +++ b/src/texturelab/widgets/properties/propwidgets.cpp @@ -15,34 +15,50 @@ #include #include #include +#include #include #include #include #include +#include +#include const int SLIDER_MAX = 1000; +class NoWheelSlider : public QSlider { +public: + using QSlider::QSlider; + void wheelEvent(QWheelEvent* event) override { event->ignore(); } +}; + +class NoWheelComboBox : public QComboBox { +public: + using QComboBox::QComboBox; + void wheelEvent(QWheelEvent* event) override { event->ignore(); } +}; + // FLOAT PROP WIDGET // https://stackoverflow.com/a/19007951 FloatPropWidget::FloatPropWidget() { prop = nullptr; + updating = false; auto vlayout = new QVBoxLayout(this); this->setLayout(vlayout); - // label label = new QLabel(this); label->setText(""); vlayout->addWidget(label); - // slider - slider = new QSlider(Qt::Horizontal, this); + slider = new NoWheelSlider(Qt::Horizontal, this); slider->setMinimum(0); slider->setMaximum(SLIDER_MAX); slider->setSingleStep(1); spinbox = new QDoubleSpinBox(this); + spinbox->setMaximum(std::numeric_limits::max()); + spinbox->setFixedWidth(60); auto hbox = new QHBoxLayout(); hbox->addWidget(slider); @@ -53,43 +69,54 @@ FloatPropWidget::FloatPropWidget() this->setFixedHeight(80); connect(slider, &QSlider::valueChanged, [=](int val) { - auto percent = val / (float)SLIDER_MAX; - if (prop) { - auto range = prop->maxValue - prop->minValue; - auto finalValue = prop->minValue + range * percent; - spinbox->setValue(finalValue); - - emit valueChanged(finalValue); - } + if (updating || !prop) + return; + updating = true; + auto range = prop->maxValue - prop->minValue; + auto finalValue = prop->minValue + range * (val / (double)SLIDER_MAX); + spinbox->setValue(finalValue); + updating = false; + emit valueChanged(finalValue); }); connect(spinbox, &QDoubleSpinBox::valueChanged, [=](double val) { - if (prop) { - auto range = prop->maxValue - prop->minValue; - auto finalValue = ((val - prop->minValue) / range) * SLIDER_MAX; - - slider->setValue(finalValue); - - emit valueChanged(val); - } + if (updating || !prop) + return; + updating = true; + auto range = prop->maxValue - prop->minValue; + int sliderVal = + (range > 0) + ? qBound(0, (int)((val - prop->minValue) / range * SLIDER_MAX), + SLIDER_MAX) + : 0; + slider->setValue(sliderVal); + updating = false; + emit valueChanged(val); }); } void FloatPropWidget::setProp(FloatProp* prop) { + this->prop = prop; + updating = true; + label->setText(prop->displayName); spinbox->setMinimum(prop->minValue); - spinbox->setMaximum(prop->maxValue); + spinbox->setMaximum(std::numeric_limits::max()); spinbox->setSingleStep(prop->step); spinbox->setValue(prop->value); auto range = prop->maxValue - prop->minValue; - auto finalValue = ((prop->value - prop->minValue) / range) * SLIDER_MAX; - - slider->setValue(finalValue); - - this->prop = prop; + int sliderVal = + (range > 0) + ? qBound(0, + (int)((prop->value - prop->minValue) / range * SLIDER_MAX), + SLIDER_MAX) + : 0; + slider->setValue(sliderVal); + + updating = false; } // INT PROP WIDGET @@ -97,22 +124,23 @@ void FloatPropWidget::setProp(FloatProp* prop) IntPropWidget::IntPropWidget() { prop = nullptr; + updating = false; auto vlayout = new QVBoxLayout(this); this->setLayout(vlayout); - // label label = new QLabel(this); label->setText(""); vlayout->addWidget(label); - // slider - slider = new QSlider(Qt::Horizontal, this); + slider = new NoWheelSlider(Qt::Horizontal, this); slider->setMinimum(0); slider->setMaximum(SLIDER_MAX); slider->setSingleStep(1); spinbox = new QSpinBox(this); + spinbox->setMaximum(INT_MAX); + spinbox->setFixedWidth(60); auto hbox = new QHBoxLayout(); hbox->addWidget(slider); @@ -123,38 +151,54 @@ IntPropWidget::IntPropWidget() this->setFixedHeight(80); connect(slider, &QSlider::valueChanged, [=](int val) { - auto percent = val / (float)SLIDER_MAX; - if (prop) { - spinbox->setValue(val); - - emit valueChanged(val); - } + if (updating || !prop) + return; + updating = true; + auto range = prop->maxValue - prop->minValue; + long finalValue = + prop->minValue + (long)qRound(range * (val / (double)SLIDER_MAX)); + spinbox->setValue((int)finalValue); + updating = false; + emit valueChanged(finalValue); }); connect(spinbox, &QSpinBox::valueChanged, [=](int val) { - if (prop) { - slider->setValue(val); - - emit valueChanged(val); - } + if (updating || !prop) + return; + updating = true; + auto range = prop->maxValue - prop->minValue; + int sliderVal = (range > 0) ? qBound(0, + (int)((val - prop->minValue) / + (double)range * SLIDER_MAX), + SLIDER_MAX) + : 0; + slider->setValue(sliderVal); + updating = false; + emit valueChanged((long)val); }); } void IntPropWidget::setProp(IntProp* prop) { - label->setText(prop->displayName); + this->prop = prop; + updating = true; - spinbox->setMinimum(prop->minValue); - spinbox->setMaximum(prop->maxValue); - spinbox->setSingleStep(prop->step); - spinbox->setValue(prop->value); + label->setText(prop->displayName); - slider->setValue(prop->value); - slider->setMinimum(prop->minValue); - slider->setMaximum(prop->maxValue); - slider->setSingleStep(prop->step); + spinbox->setMinimum((int)prop->minValue); + spinbox->setMaximum(INT_MAX); + spinbox->setSingleStep((int)prop->step); + spinbox->setValue((int)prop->value); - this->prop = prop; + auto range = prop->maxValue - prop->minValue; + int sliderVal = (range > 0) ? qBound(0, + (int)((prop->value - prop->minValue) / + (double)range * SLIDER_MAX), + SLIDER_MAX) + : 0; + slider->setValue(sliderVal); + + updating = false; } // ENUM PROP WIDGET @@ -172,7 +216,7 @@ EnumPropWidget::EnumPropWidget() vlayout->addWidget(label); // slider - comboBox = new QComboBox(this); + comboBox = new NoWheelComboBox(this); vlayout->addWidget(comboBox); this->setFixedHeight(80); @@ -202,29 +246,49 @@ StringPropWidget::StringPropWidget() auto vlayout = new QVBoxLayout(this); this->setLayout(vlayout); - // label label = new QLabel(this); label->setText(""); vlayout->addWidget(label); - // line edit lineEdit = new QLineEdit(this); vlayout->addWidget(lineEdit); + textEdit = new QPlainTextEdit(this); + textEdit->hide(); + vlayout->addWidget(textEdit); + this->setFixedHeight(80); connect(lineEdit, &QLineEdit::textChanged, [=](const QString& text) { emit valueChanged(text); }); + + connect(textEdit, &QPlainTextEdit::textChanged, + [=]() { emit valueChanged(textEdit->toPlainText()); }); } void StringPropWidget::setProp(StringProp* prop) { label->setText(prop->displayName); lineEdit->setText(prop->value); + textEdit->setPlainText(prop->value); this->prop = prop; } +void StringPropWidget::setMultiline(bool multiline) +{ + if (multiline) { + lineEdit->hide(); + textEdit->show(); + setFixedHeight(120); + } + else { + textEdit->hide(); + lineEdit->show(); + setFixedHeight(80); + } +} + // BOOL PROP WIDGET // https://stackoverflow.com/a/19007951 BoolPropWidget::BoolPropWidget() @@ -308,7 +372,7 @@ void ColorPropWidget::updateColorPreview() .arg(prop->value.green()) .arg(prop->value.blue()) .arg(prop->value.alpha()); - colorPreview->setStyleSheet(styleSheet); + colorPreview->setStyleSheet(styleSheet); // theme-exempt: dynamic color-data swatch } } @@ -330,9 +394,10 @@ bool ColorPropWidget::eventFilter(QObject* obj, QEvent* event) emit valueChanged(color); // signal value changed } }); + connect(picker, &ColorPicker::onClosed, picker, + &ColorPicker::deleteLater); - picker->exec(); - delete picker; + picker->show(); return true; } @@ -387,7 +452,7 @@ void GradientPropWidget::updateGradientPreview() painter.end(); QString styleSheet = QString("border: 1px solid #888;"); - gradientPreview->setStyleSheet(styleSheet); + gradientPreview->setStyleSheet(styleSheet); // theme-exempt: dynamic gradient-data swatch // Set as background using palette palette.setBrush(gradientPreview->backgroundRole(), QBrush(pixmap)); @@ -465,8 +530,7 @@ ImagePropWidget::ImagePropWidget() imagePreview->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); imagePreview->setAlignment(Qt::AlignCenter); imagePreview->setCursor(Qt::PointingHandCursor); - imagePreview->setStyleSheet( - "QLabel { background-color: #333; border: 1px solid #888; }"); + imagePreview->setObjectName("ImagePreview"); // styled in app.qss.in imagePreview->setText("Click to select image"); imagePreview->setScaledContents(false); imagePreview->installEventFilter(this); @@ -523,6 +587,8 @@ bool ImagePropWidget::eventFilter(QObject* obj, QEvent* event) filePath = fileName; QImage image(fileName); if (!image.isNull()) { + if (image.format() != QImage::Format_RGBA8888) + image = image.convertToFormat(QImage::Format_RGBA8888); if (prop) { prop->value = image; updateImagePreview(); diff --git a/src/texturelab/widgets/properties/propwidgets.h b/src/texturelab/widgets/properties/propwidgets.h index 14091de6..26e6a3f0 100644 --- a/src/texturelab/widgets/properties/propwidgets.h +++ b/src/texturelab/widgets/properties/propwidgets.h @@ -9,6 +9,7 @@ class QSpinBox; class QComboBox; class QPushButton; class QLineEdit; +class QPlainTextEdit; struct FloatProp; struct IntProp; @@ -29,6 +30,7 @@ class FloatPropWidget : public QWidget { QDoubleSpinBox* spinbox; FloatProp* prop; + bool updating; public: FloatPropWidget(); @@ -45,6 +47,7 @@ class IntPropWidget : public QWidget { QSpinBox* spinbox; IntProp* prop; + bool updating; public: IntPropWidget(); @@ -73,12 +76,14 @@ class StringPropWidget : public QWidget { QLabel* label; QLineEdit* lineEdit; + QPlainTextEdit* textEdit; StringProp* prop; public: StringPropWidget(); void setProp(StringProp* prop); + void setMultiline(bool multiline); signals: void valueChanged(QString); }; diff --git a/src/texturelab/widgets/view2dwidget.cpp b/src/texturelab/widgets/view2dwidget.cpp index 450327a7..65733621 100644 --- a/src/texturelab/widgets/view2dwidget.cpp +++ b/src/texturelab/widgets/view2dwidget.cpp @@ -1,5 +1,8 @@ #include "view2dwidget.h" +#include "thememanager.h" +#include "tokens.h" #include +#include #include #include @@ -40,10 +43,11 @@ const QColor CoarseGridColor(25, 25, 25); View2DWidget::View2DWidget() : QMainWindow() { - // Create toolbar + // Create toolbar (compact — see #View2DToolbar in app.qss.in) toolbar = new QToolBar(this); + toolbar->setObjectName("View2DToolbar"); toolbar->setMovable(false); - toolbar->setIconSize(QSize(24, 24)); + toolbar->setIconSize(QSize(18, 18)); this->addToolBar(Qt::TopToolBarArea, toolbar); // Add save button @@ -81,7 +85,11 @@ void View2DWidget::setSelectedNode(const TextureNodePtr& node) this->graph->setSelectedNode(node); } -void View2DWidget::clearSelection() {} +void View2DWidget::clearSelection() +{ + this->node.reset(); + this->graph->clearSelection(); +} void View2DWidget::reRenderNode() { @@ -199,12 +207,7 @@ void View2DWidget::copyTextureToClipboard() void View2DWidget::showToast(const QString& message, int duration) { QLabel* toast = new QLabel(message, this); - toast->setStyleSheet("QLabel {" - " background-color: rgba(50, 50, 50, 200);" - " color: white;" - " padding: 10px 20px;" - " border-radius: 5px;" - "}"); + toast->setObjectName("ViewToast"); // styled in app.qss.in toast->setAlignment(Qt::AlignCenter); toast->adjustSize(); @@ -253,7 +256,15 @@ View2DGraph::View2DGraph(QWidget* parent) : QGraphicsView(parent) setDragMode(QGraphicsView::ScrollHandDrag); setRenderHint(QPainter::Antialiasing); - setBackgroundBrush(QColor(33, 33, 33)); + setBackgroundBrush(ThemeManager::instance().theme().color(Tokens::View2dBg)); + QObject::connect(&ThemeManager::instance(), &ThemeManager::themeChanged, this, + [this]() { + setBackgroundBrush( + ThemeManager::instance().theme().color(Tokens::View2dBg)); + if (scene()) + scene()->update(); + viewport()->update(); + }); setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); @@ -315,20 +326,22 @@ void View2DGraph::scaleDown() // void View2DGraph::keyReleaseEvent(QKeyEvent* event){}; void View2DGraph::mousePressEvent(QMouseEvent* event) { - if (event->button() == Qt::MiddleButton && - scene()->mouseGrabberItem() == nullptr) { - _clickPos = mapToScene(event->pos()); + if (event->button() == Qt::MiddleButton) { + _clickPos = event->pos(); setDragMode(QGraphicsView::NoDrag); + return; } QGraphicsView::mousePressEvent(event); } void View2DGraph::mouseMoveEvent(QMouseEvent* event) { - - if (event->buttons() == Qt::MiddleButton) { - QPointF difference = _clickPos - mapToScene(event->pos()); - setSceneRect(sceneRect().translated(difference.x(), difference.y())); + if (event->buttons() & Qt::MiddleButton) { + QPointF delta = event->pos() - _clickPos; + qreal s = transform().m11(); + setSceneRect(sceneRect().translated(-delta.x() / s, -delta.y() / s)); + _clickPos = event->pos(); + return; } QGraphicsView::mouseMoveEvent(event); } @@ -336,6 +349,7 @@ void View2DGraph::mouseMoveEvent(QMouseEvent* event) void View2DGraph::mouseReleaseEvent(QMouseEvent* event) { if (event->button() == Qt::MiddleButton) { + setDragMode(QGraphicsView::ScrollHandDrag); } QGraphicsView::mouseReleaseEvent(event); } @@ -349,7 +363,12 @@ void View2DGraph::setSelectedNode(const TextureNodePtr& node) void View2DGraph::updatePreview() { this->preview->update(); } -void View2DGraph::clearSelection() {}; +void View2DGraph::clearSelection() +{ + this->preview->clearNode(); + this->preview->hide(); + this->preview->update(); +}; void View2DGraph::drawBackground(QPainter* painter, const QRectF& r) { @@ -437,8 +456,16 @@ void NodePreviewGraphicsItem::initializeGL() in vec2 vTexCoord; out vec4 fragColor; uniform sampler2D textureSampler; + uniform vec3 checkerA; + uniform vec3 checkerB; void main() { - fragColor = texture(textureSampler, vTexCoord); + // 16px checkerboard in screen space + vec2 tile = floor(gl_FragCoord.xy / 16.0); + float checker = mod(tile.x + tile.y, 2.0); + vec3 bg = mix(checkerA, checkerB, checker); + + vec4 texColor = texture(textureSampler, vTexCoord); + fragColor = vec4(mix(bg, texColor.rgb, texColor.a), 1.0); } )"; @@ -586,6 +613,16 @@ void NodePreviewGraphicsItem::paint(QPainter* painter, shaderProgram->bind(); shaderProgram->setUniformValue("projectionMatrix", projectionMatrix); shaderProgram->setUniformValue("textureSampler", 0); + + // Themed checkerboard (matches the node-graph checker); read each paint so it + // follows theme changes / --dev-theme hot-reload. + const Theme& theme = ThemeManager::instance().theme(); + const QColor ca = theme.color(Tokens::CheckerA); + const QColor cb = theme.color(Tokens::CheckerB); + shaderProgram->setUniformValue("checkerA", + QVector3D(ca.redF(), ca.greenF(), ca.blueF())); + shaderProgram->setUniformValue("checkerB", + QVector3D(cb.redF(), cb.greenF(), cb.blueF())); // Bind texture f->glActiveTexture(GL_TEXTURE0); diff --git a/src/texturelab/widgets/view3dwidget.cpp b/src/texturelab/widgets/view3dwidget.cpp index e0416070..55bf3c23 100644 --- a/src/texturelab/widgets/view3dwidget.cpp +++ b/src/texturelab/widgets/view3dwidget.cpp @@ -1,77 +1,126 @@ #include "view3dwidget.h" #include "viewer3d.h" #include +#include #include #include +#include +#include + +namespace { + +// Available meshes. `modelType` is the id Viewer3D::setModel() switches on. +struct ModelInfo { + QString displayName; + QString modelType; +}; + +const QVector& models() +{ + static const QVector modelList = { + {"Sphere", "sphere"}, + {"Plane (XY)", "plane_xy"}, + {"Plane (YZ)", "plane_yz"}, + {"Plane (XZ)", "plane_xz"}, + {"Cylinder", "cylinder"}, + {"Cube", "cube"}, + {"CubeSphere", "cubesphere"}}; + return modelList; +} + +// Available HDR environments. +// +// `rotation` is a yaw in degrees about the up axis, applied to both the skybox +// and the IBL lookups (Renderer::envRotation). Several of these HDRIs point +// their darkest quarter at the default camera, which sits on -Z, so the model +// opened as a silhouette. Each value was picked by maximising the diffuse +// irradiance on a normal 30 degrees off the camera-facing one, which lands the +// sky's brightest arc behind and to the left of the camera as a key light. +struct EnvInfo { + QString displayName; + QString resourcePath; + float rotation; +}; + +const QVector& environments() +{ + static const QVector envList = { + {"Docklands 01", ":env/docklands_01_1k.hdr", -100.0f}, + {"Golden Bay", ":env/golden_bay_1k.hdr", -85.0f}, + {"Little Paris Eiffel Tower", ":env/little_paris_eiffel_tower_1k.hdr", + -95.0f}, + {"Sepulchral Chapel Basement", ":env/sepulchral_chapel_basement_1k.hdr", + 25.0f}, + {"St Peters Square Night", ":env/st_peters_square_night_1k.hdr", + -95.0f}, + {"Stadium 01", ":env/stadium_01_1k.hdr", -120.0f}, + {"Studio Kontrast 03", ":env/studio_kontrast_03_1k.hdr", 100.0f}, + {"Sunny Rose Garden", ":env/sunny_rose_garden_1k.hdr", -95.0f}, + {"University Workshop", ":env/university_workshop_1k.hdr", 140.0f}, + {"Winter River", ":env/winter_river_1k.hdr", -100.0f}}; + return envList; +} + +// The env the viewer opens on. Kept in sync with kFallbackEnvPath/Rotation in +// viewer3d.cpp, which covers hosts that never call setDefaultEnvironment(). +const EnvInfo& defaultEnvironment() +{ + const QString defaultPath = + QStringLiteral(":env/studio_kontrast_03_1k.hdr"); + for (const EnvInfo& env : environments()) { + if (env.resourcePath == defaultPath) + return env; + } + return environments().first(); +} + +} // namespace View3DWidget::View3DWidget() { this->viewer = new Viewer3D(); this->setCentralWidget(viewer); - this->viewer->setDefaultEnvironment(":env/cave_wall_1k.hdr"); + const EnvInfo& defaultEnv = defaultEnvironment(); + this->viewer->setDefaultEnvironment(defaultEnv.resourcePath, + defaultEnv.rotation); // Create menu bar QMenuBar* menuBar = new QMenuBar(this); this->setMenuBar(menuBar); - // Model menu + // Model menu. Exclusive like the environment menu below, so the mesh in + // use carries a check mark. The initial check matches the mesh Viewer3D + // builds in its constructor. QMenu* modelMenu = menuBar->addMenu("Model"); + QActionGroup* modelGroup = new QActionGroup(this); + const QString defaultModelType = QStringLiteral("sphere"); + + for (const ModelInfo& model : models()) { + QAction* modelAction = modelMenu->addAction(model.displayName); + modelAction->setCheckable(true); + modelAction->setChecked(model.modelType == defaultModelType); + modelGroup->addAction(modelAction); + QString modelType = model.modelType; + connect(modelAction, &QAction::triggered, + [this, modelType]() { this->viewer->setModel(modelType); }); + } - QAction* sphereAction = modelMenu->addAction("Sphere"); - connect(sphereAction, &QAction::triggered, - [this]() { this->viewer->setModel("sphere"); }); - - QAction* planeXYAction = modelMenu->addAction("Plane (XY)"); - connect(planeXYAction, &QAction::triggered, - [this]() { this->viewer->setModel("plane_xy"); }); - - QAction* planeYZAction = modelMenu->addAction("Plane (YZ)"); - connect(planeYZAction, &QAction::triggered, - [this]() { this->viewer->setModel("plane_yz"); }); - - QAction* planeXZAction = modelMenu->addAction("Plane (XZ)"); - connect(planeXZAction, &QAction::triggered, - [this]() { this->viewer->setModel("plane_xz"); }); - - QAction* cylinderAction = modelMenu->addAction("Cylinder"); - connect(cylinderAction, &QAction::triggered, - [this]() { this->viewer->setModel("cylinder"); }); - - QAction* cubeAction = modelMenu->addAction("Cube"); - connect(cubeAction, &QAction::triggered, - [this]() { this->viewer->setModel("cube"); }); - - QAction* cubesphereAction = modelMenu->addAction("CubeSphere"); - connect(cubesphereAction, &QAction::triggered, - [this]() { this->viewer->setModel("cubesphere"); }); - - // Environment menu + // Environment menu. The entries form an exclusive group so the sky in use + // carries a check mark. QMenu* envMenu = menuBar->addMenu("Environment"); + QActionGroup* envGroup = new QActionGroup(this); - // List of available HDR environments - struct EnvInfo { - QString displayName; - QString resourcePath; - }; - - QVector envList = { - {"Cave Wall", ":env/cave_wall_1k.hdr"}, - {"Christmas", ":env/christmas_1k.hdr"}, - {"Dresden Station Night", ":env/dresden_station_night_1k.hdr"}, - {"Hansaplatz", ":env/hansaplatz_1k.hdr"}, - {"Kloppenheim 05", ":env/kloppenheim_05_1k.hdr"}, - {"Modern Buildings Night", ":env/modern_buildings_night_1k.hdr"}, - {"Snowy Park 01", ":env/snowy_park_01_1k.hdr"}, - {"Spruit Sunrise", ":env/spruit_sunrise_1k.hdr"}, - {"Studio Small 07", ":env/studio_small_07_1k.hdr"}, - {"Wide Street 01", ":env/wide_street_01_1k.hdr"}}; - - for (const EnvInfo& env : envList) { + for (const EnvInfo& env : environments()) { QAction* envAction = envMenu->addAction(env.displayName); + envAction->setCheckable(true); + envAction->setChecked(env.resourcePath == defaultEnv.resourcePath); + envGroup->addAction(envAction); QString path = env.resourcePath; - connect(envAction, &QAction::triggered, - [this, path]() { this->viewer->loadEnvironment(path); }); + float rotation = env.rotation; + connect(envAction, &QAction::triggered, [this, path, rotation]() { + this->viewer->loadEnvironment(path, rotation); + }); } } diff --git a/src/theme/CMakeLists.txt b/src/theme/CMakeLists.txt new file mode 100644 index 00000000..3fab8498 --- /dev/null +++ b/src/theme/CMakeLists.txt @@ -0,0 +1,36 @@ +cmake_minimum_required(VERSION 3.10) + +find_package(QT NAMES Qt6 Qt5 COMPONENTS Core REQUIRED) +find_package(Qt${QT_VERSION_MAJOR} COMPONENTS Core Gui Widgets REQUIRED) + +set(CMAKE_INCLUDE_CURRENT_DIR ON) + +set(THEME_SRCS + theme.cpp + qssbuilder.cpp + thememanager.cpp +) +set(THEME_HEADERS + tokens.h + theme.h + qssbuilder.h + thememanager.h +) + +add_library(theme STATIC ${THEME_SRCS} ${THEME_HEADERS}) + +target_link_libraries(theme PUBLIC Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Gui + Qt${QT_VERSION_MAJOR}::Widgets) + +target_include_directories(theme PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) + +set_target_properties(theme PROPERTIES + AUTOMOC ON + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + CXX_EXTENSIONS OFF + ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib" + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" +) diff --git a/src/theme/qssbuilder.cpp b/src/theme/qssbuilder.cpp new file mode 100644 index 00000000..0726c37b --- /dev/null +++ b/src/theme/qssbuilder.cpp @@ -0,0 +1,37 @@ +#include "qssbuilder.h" + +#include "theme.h" + +#include + +QString QssBuilder::build(const QString& templateText, const Theme& theme) +{ + const QHash& vars = theme.qssVars(); + + // Matches {{ token.name }} with optional surrounding whitespace. + static const QRegularExpression re(QStringLiteral("\\{\\{\\s*([^}\\s]+)\\s*\\}\\}")); + + QString out; + out.reserve(templateText.size()); + + qsizetype last = 0; + auto it = re.globalMatch(templateText); + while (it.hasNext()) { + const QRegularExpressionMatch m = it.next(); + out += templateText.mid(last, m.capturedStart() - last); + + const QString key = m.captured(1); + auto found = vars.constFind(key); + if (found != vars.constEnd()) { + out += found.value(); + } + else { + qWarning("QssBuilder: unknown token '{{%s}}' left unsubstituted", + qPrintable(key)); + out += m.captured(0); // leave placeholder so the miss is visible + } + last = m.capturedEnd(); + } + out += templateText.mid(last); + return out; +} diff --git a/src/theme/qssbuilder.h b/src/theme/qssbuilder.h new file mode 100644 index 00000000..ce5478c6 --- /dev/null +++ b/src/theme/qssbuilder.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +class Theme; + +// Turns an authored QSS template (with {{token}} placeholders) into a final +// stylesheet string by substituting values from the active theme. +namespace QssBuilder { + +// Substitute every {{token}} in `templateText` with theme.qssVars()[token]. +// Unknown tokens are left as-is and a warning is logged, so a typo is visible +// in the rendered CSS rather than silently blanking a rule. +QString build(const QString& templateText, const Theme& theme); + +} // namespace QssBuilder diff --git a/src/theme/theme.cpp b/src/theme/theme.cpp new file mode 100644 index 00000000..fb13dd04 --- /dev/null +++ b/src/theme/theme.cpp @@ -0,0 +1,11 @@ +#include "theme.h" + +QColor Theme::color(const QString& key) const +{ + auto it = m_colors.constFind(key); + if (it == m_colors.constEnd()) { + qWarning("Theme: unknown color token '%s'", qPrintable(key)); + return QColor(255, 0, 255); // loud magenta = obvious mistake + } + return it.value(); +} diff --git a/src/theme/theme.h b/src/theme/theme.h new file mode 100644 index 00000000..bf4d0f8a --- /dev/null +++ b/src/theme/theme.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include +#include +#include + +// A resolved, immutable snapshot of one theme. Produced by ThemeManager from a +// theme JSON file (all "@ref" indirection already flattened to concrete values). +// +// Consumers: +// - QssBuilder reads the flat string map (qssVars) for {{token}} substitution. +// - Custom paint code reads typed values via color()/radius()/space()/font(). +// - ThemeManager reads paletteSpec to build a QPalette. +class Theme +{ +public: + Theme() = default; + + // Typed lookups for paint-time code. A missing color returns a loud magenta + // (so a mistyped token is obvious on screen rather than silently black). + QColor color(const QString& key) const; + bool hasColor(const QString& key) const { return m_colors.contains(key); } + int radius(const QString& key) const { return m_ints.value("radius." + key, 0); } + int space(const QString& key) const { return m_ints.value("space." + key, 0); } + int motion(const QString& key) const { return m_ints.value("motion." + key, 0); } + QFont font(const QString& key) const { return m_fonts.value(key); } + + const QString& name() const { return m_name; } + + // Flat name->string map used for QSS {{token}} substitution. Colors are + // "#rrggbb", numbers stringified, font sub-fields as "font.ui.family" etc. + const QHash& qssVars() const { return m_qssVars; } + + // role -> resolved QColor, used to build the QPalette. + const QHash& paletteColors() const { return m_paletteColors; } + +private: + friend class ThemeManager; + + QString m_name; + QHash m_colors; // "bg.window" -> QColor + QHash m_ints; // "radius.sm" / "space.md" / "motion.fast" + QHash m_fonts; // "ui" / "mono" / "title" + QHash m_qssVars; // flat map for template substitution + QHash m_paletteColors; // "window" / "disabled.text" -> QColor +}; diff --git a/src/theme/thememanager.cpp b/src/theme/thememanager.cpp new file mode 100644 index 00000000..6df8db69 --- /dev/null +++ b/src/theme/thememanager.cpp @@ -0,0 +1,272 @@ +#include "thememanager.h" + +#include "qssbuilder.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +// Resolve a JSON string value that may be an "@ref" pointing at another key in +// `colors`. Follows a chain of references with a guard against cycles. +QColor resolveColor(const QString& raw, const QJsonObject& colors) +{ + QString v = raw; + int guard = 0; + while (v.startsWith('@')) { + if (++guard > 32) { + qWarning("ThemeManager: color reference cycle at '%s'", qPrintable(raw)); + return QColor(); + } + const QString ref = v.mid(1); + if (!colors.contains(ref)) { + qWarning("ThemeManager: dangling color reference '@%s'", qPrintable(ref)); + return QColor(); + } + v = colors.value(ref).toString(); + } + QColor c(v); + if (!c.isValid()) + qWarning("ThemeManager: invalid color literal '%s'", qPrintable(v)); + return c; +} + +} // namespace + +ThemeManager& ThemeManager::instance() +{ + static ThemeManager s_instance; + return s_instance; +} + +bool ThemeManager::loadFromResource(const QString& resourcePath) +{ + QFile file(resourcePath); + if (!file.open(QIODevice::ReadOnly)) { + qWarning("ThemeManager: cannot open theme '%s'", qPrintable(resourcePath)); + return false; + } + + QJsonParseError err{}; + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &err); + if (err.error != QJsonParseError::NoError || !doc.isObject()) { + qWarning("ThemeManager: JSON parse error in '%s': %s", qPrintable(resourcePath), + qPrintable(err.errorString())); + return false; + } + + const QJsonObject root = doc.object(); + const QJsonObject colors = root.value("color").toObject(); + + Theme t; + t.m_name = root.value("meta").toObject().value("name").toString(); + + // --- colors (resolve @refs) --- + for (auto it = colors.constBegin(); it != colors.constEnd(); ++it) { + const QColor c = resolveColor(it.value().toString(), colors); + t.m_colors.insert(it.key(), c); + t.m_qssVars.insert(it.key(), c.name(QColor::HexRgb)); // "#rrggbb" + } + + // --- scalar groups: radius / space / motion --- + const auto loadInts = [&](const char* group) { + const QJsonObject obj = root.value(group).toObject(); + for (auto it = obj.constBegin(); it != obj.constEnd(); ++it) { + const int val = it.value().toInt(); + const QString flatKey = QString("%1.%2").arg(group, it.key()); + t.m_ints.insert(flatKey, val); + t.m_qssVars.insert(flatKey, QString::number(val)); + } + }; + loadInts("radius"); + loadInts("space"); + loadInts("motion"); + + // --- fonts --- + const QJsonObject fonts = root.value("font").toObject(); + for (auto it = fonts.constBegin(); it != fonts.constEnd(); ++it) { + const QJsonObject f = it.value().toObject(); + const QString family = f.value("family").toString(); + const int size = f.value("size").toInt(12); + const int weight = f.value("weight").toInt(400); + + QFont font; + // Family may be a CSS-style fallback list; take the first as the primary + // and register the rest as substitute candidates via setFamilies. + QStringList families; + for (const QString& part : family.split(',')) + families << part.trimmed(); + if (!families.isEmpty()) { + font.setFamily(families.first()); + font.setFamilies(families); + } + font.setPixelSize(size); + font.setWeight(QFont::Weight(weight)); + t.m_fonts.insert(it.key(), font); + + t.m_qssVars.insert(QString("font.%1.family").arg(it.key()), family); + t.m_qssVars.insert(QString("font.%1.size").arg(it.key()), QString::number(size)); + t.m_qssVars.insert(QString("font.%1.weight").arg(it.key()), QString::number(weight)); + } + + // --- palette role map --- + const QJsonObject palette = root.value("palette").toObject(); + for (auto it = palette.constBegin(); it != palette.constEnd(); ++it) { + t.m_paletteColors.insert(it.key(), resolveColor(it.value().toString(), colors)); + } + + m_theme = t; + return true; +} + +void ThemeManager::setStyleSheetTemplate(const QString& resourcePath) +{ + QFile file(resourcePath); + if (!file.open(QIODevice::ReadOnly)) { + qWarning("ThemeManager: cannot open QSS template '%s'", qPrintable(resourcePath)); + m_qssTemplate.clear(); + return; + } + m_qssTemplate = QString::fromUtf8(file.readAll()); +} + +void ThemeManager::setAdsStyleSheetTemplate(const QString& resourcePath) +{ + QFile file(resourcePath); + if (!file.open(QIODevice::ReadOnly)) { + qWarning("ThemeManager: cannot open ADS QSS template '%s'", qPrintable(resourcePath)); + m_adsTemplate.clear(); + return; + } + m_adsTemplate = QString::fromUtf8(file.readAll()); +} + +QString ThemeManager::adsStyleSheet() const +{ + return QssBuilder::build(m_adsTemplate, m_theme); +} + +QString ThemeManager::appStyleSheet() const +{ + return QssBuilder::build(m_qssTemplate, m_theme); +} + +QPalette ThemeManager::buildPalette() const +{ + const QHash& p = m_theme.paletteColors(); + const auto col = [&](const char* role, QColor fallback) { + return p.value(QString::fromLatin1(role), fallback); + }; + + QPalette pal; + pal.setColor(QPalette::Window, col("window", QColor(53, 53, 53))); + pal.setColor(QPalette::WindowText, col("windowText", Qt::white)); + pal.setColor(QPalette::Base, col("base", QColor(35, 35, 35))); + pal.setColor(QPalette::AlternateBase, col("alternateBase", QColor(53, 53, 53))); + pal.setColor(QPalette::ToolTipBase, col("toolTipBase", QColor(25, 25, 25))); + pal.setColor(QPalette::ToolTipText, col("toolTipText", Qt::white)); + pal.setColor(QPalette::Text, col("text", Qt::white)); + pal.setColor(QPalette::Button, col("button", QColor(53, 53, 53))); + pal.setColor(QPalette::ButtonText, col("buttonText", Qt::white)); + pal.setColor(QPalette::BrightText, col("brightText", Qt::red)); + pal.setColor(QPalette::Link, col("link", QColor(42, 130, 218))); + pal.setColor(QPalette::Highlight, col("highlight", QColor(42, 130, 218))); + pal.setColor(QPalette::HighlightedText, col("highlightedText", Qt::black)); + + const QColor disabled = col("disabled.text", QColor(127, 127, 127)); + pal.setColor(QPalette::Disabled, QPalette::WindowText, + col("disabled.windowText", disabled)); + pal.setColor(QPalette::Disabled, QPalette::Text, disabled); + pal.setColor(QPalette::Disabled, QPalette::ButtonText, + col("disabled.buttonText", disabled)); + pal.setColor(QPalette::Disabled, QPalette::HighlightedText, + col("disabled.highlightedText", disabled)); + pal.setColor(QPalette::Disabled, QPalette::Highlight, + col("disabled.highlight", QColor(80, 80, 80))); + return pal; +} + +void ThemeManager::applyToApplication(QApplication& app) +{ + m_app = &app; + + app.setStyle(QStyleFactory::create("Fusion")); +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + app.styleHints()->setColorScheme(Qt::ColorScheme::Dark); +#endif + + reapply(); +} + +void ThemeManager::reapply() +{ + if (!m_app) + return; + + m_app->setPalette(buildPalette()); + m_app->setStyleSheet(QssBuilder::build(m_qssTemplate, m_theme)); + + emit themeChanged(); +} + +void ThemeManager::enableHotReload(const QString& themeFilePath, const QString& qssFilePath, + const QString& adsFilePath) +{ + m_themePath = themeFilePath; + m_qssPath = qssFilePath; + m_adsPath = adsFilePath; + + if (!m_watcher) { + m_watcher = new QFileSystemWatcher(this); + + // Coalesce bursts of change events (editors often fire several per save) + // into a single reload. + m_reloadTimer = new QTimer(this); + m_reloadTimer->setSingleShot(true); + m_reloadTimer->setInterval(120); + connect(m_reloadTimer, &QTimer::timeout, this, &ThemeManager::reloadFromDisk); + connect(m_watcher, &QFileSystemWatcher::fileChanged, this, + [this](const QString&) { m_reloadTimer->start(); }); + } + + // Initial load from the on-disk source, then start watching. + reloadFromDisk(); +} + +void ThemeManager::reloadFromDisk() +{ + // QFile handles plain filesystem paths as well as ":/..." resources. + const bool ok = loadFromResource(m_themePath); // keeps previous theme if parse fails + setStyleSheetTemplate(m_qssPath); + if (!m_adsPath.isEmpty()) + setAdsStyleSheetTemplate(m_adsPath); + reapply(); // emits themeChanged() -> MainWindow re-applies the ADS sheet + + // --dev-theme feedback. Use fprintf, NOT qInfo/qWarning: the app installs a + // custom Qt message handler that routes logging to Sentry breadcrumbs, which + // would swallow this and defeat the point of a live-tuning loop. + std::fprintf(stderr, "[theme] %s: reloaded from %s\n", + ok ? "ok" : "FAILED (kept previous theme)", qPrintable(m_themePath)); + std::fflush(stderr); + + // Many editors save by writing a temp file and renaming over the original, + // which deletes the inode QFileSystemWatcher was tracking and silently drops + // the watch. Re-add any path the watcher is no longer following. + if (m_watcher) { + const QStringList watched = m_watcher->files(); + for (const QString& p : { m_themePath, m_qssPath, m_adsPath }) { + if (!p.isEmpty() && !watched.contains(p) && QFile::exists(p)) + m_watcher->addPath(p); + } + } +} diff --git a/src/theme/thememanager.h b/src/theme/thememanager.h new file mode 100644 index 00000000..254cb42e --- /dev/null +++ b/src/theme/thememanager.h @@ -0,0 +1,87 @@ +#pragma once + +#include "theme.h" + +#include +#include +#include + +class QApplication; +class QFileSystemWatcher; +class QTimer; + +// Owns the active Theme and applies it to the application. Singleton so paint +// code anywhere can read the current theme and subscribe to themeChanged(). +// +// Phase 0: loads a JSON theme, builds a QPalette identical to the previous +// hand-coded applyDarkTheme(), applies Fusion + palette + (empty) stylesheet. +// Later phases fill in app.qss and thread tokens into custom paint code. +class ThemeManager : public QObject +{ + Q_OBJECT + +public: + static ThemeManager& instance(); + + // Load + resolve a theme JSON (e.g. ":/themes/dark.json"). Returns false and + // keeps the previous theme on parse failure. Does not apply on its own. + bool loadFromResource(const QString& resourcePath); + + // Load the QSS template (e.g. ":/qss/app.qss"). Kept separately so it can be + // re-read on hot-reload without re-parsing the theme. + void setStyleSheetTemplate(const QString& resourcePath); + + // Load the dock-system (ADS) QSS override template. Applied by MainWindow to + // the CDockManager, not to qApp (ADS sets its own sheet on the manager). Kept + // here so it participates in token substitution and hot-reload. + void setAdsStyleSheetTemplate(const QString& resourcePath); + + // Token-substituted ADS override stylesheet. MainWindow appends this to ADS's + // own default sheet. Rebuilds from the current theme on each call. + QString adsStyleSheet() const; + + // The built application stylesheet (same one applied to qApp). MainWindow also + // appends this to the dock-manager sheet: Qt prefers an ancestor widget's + // stylesheet over qApp, so without this the app rules don't reach widgets + // living inside ADS docks (e.g. the properties panel). Rebuilds each call. + QString appStyleSheet() const; + + const Theme& theme() const { return m_theme; } + + // Apply Fusion style, dark color scheme, the built QPalette, and the built + // stylesheet to the given application. Emits themeChanged(). + void applyToApplication(QApplication& app); + + // Rebuild + reapply palette/stylesheet from the current theme (used after a + // hot-reload). No-op if applyToApplication() was never called. + void reapply(); + + QPalette buildPalette() const; + + // Dev convenience: watch the on-disk *source* theme + QSS files and reload + // live on save (no rebuild needed). Pass real filesystem paths, not ":/..." + // resource paths — the compiled-in resources can't be watched. Does an + // initial load from those paths, so in dev the disk files win over the qrc. + void enableHotReload(const QString& themeFilePath, const QString& qssFilePath, + const QString& adsFilePath); + +signals: + void themeChanged(); + +private: + ThemeManager() = default; + + void reloadFromDisk(); + + Theme m_theme; + QString m_qssTemplate; // raw app template text (with {{tokens}}) + QString m_adsTemplate; // raw dock-system (ADS) override template + QApplication* m_app = nullptr; + + // hot-reload (dev only; null unless enableHotReload() was called) + QFileSystemWatcher* m_watcher = nullptr; + QTimer* m_reloadTimer = nullptr; + QString m_themePath; + QString m_qssPath; + QString m_adsPath; +}; diff --git a/src/theme/tokens.h b/src/theme/tokens.h new file mode 100644 index 00000000..ddb87177 --- /dev/null +++ b/src/theme/tokens.h @@ -0,0 +1,75 @@ +#pragma once + +// Canonical token keys. Paint code (node graph, viewports, custom widgets) and +// palette-building should reference these constants instead of hardcoding the +// dotted strings, so a rename is a compile-time break rather than a silent miss. +// +// The string values MUST match the keys under "color" in the theme JSON +// (resources/themes/*.json). +namespace Tokens { + +// --- semantic roles (surface A/B — general chrome) --- +constexpr const char* BgWindow = "bg.window"; +constexpr const char* BgPanel = "bg.panel"; +constexpr const char* BgBase = "bg.base"; +constexpr const char* BgElevated = "bg.elevated"; +constexpr const char* BorderSubtle = "border.subtle"; +constexpr const char* BorderStrong = "border.strong"; +constexpr const char* TextPrimary = "text.primary"; +constexpr const char* TextSecondary = "text.secondary"; +constexpr const char* TextDisabled = "text.disabled"; +constexpr const char* Selection = "selection"; +constexpr const char* Accent = "accent"; + +// --- node graph (surface C) --- +constexpr const char* NodeBg = "node.bg"; +constexpr const char* NodeBorder = "node.border"; +constexpr const char* NodeBorderHover = "node.border.hover"; +constexpr const char* NodeBorderSelect = "node.border.select"; +constexpr const char* NodeTitle = "node.title"; +constexpr const char* NodeChannel = "node.channel"; +constexpr const char* SocketFill = "socket.fill"; +constexpr const char* Wire = "wire"; +constexpr const char* WireDragging = "wire.dragging"; +constexpr const char* WireSelected = "wire.selected"; +constexpr const char* GridBg = "grid.bg"; +constexpr const char* GridFine = "grid.fine"; +constexpr const char* GridCoarse = "grid.coarse"; +constexpr const char* CheckerA = "checker.a"; +constexpr const char* CheckerB = "checker.b"; +constexpr const char* FrameSelect = "frame.select"; +constexpr const char* CommentFill = "comment.fill"; +constexpr const char* CommentText = "comment.text"; + +// --- launcher / project manager (surface B) --- +constexpr const char* LauncherCard = "launcher.card"; +constexpr const char* LauncherCardHover = "launcher.card.hover"; +constexpr const char* LauncherCardBorder = "launcher.card.border"; +constexpr const char* LauncherThumbBg = "launcher.thumb.bg"; +constexpr const char* LauncherStar = "launcher.star"; +constexpr const char* LauncherBadge = "launcher.badge"; +constexpr const char* LauncherPipOff = "launcher.pip.off"; +constexpr const char* LauncherPipOn = "launcher.pip.on"; +constexpr const char* LauncherOpenDot = "launcher.open.dot"; + +// --- 2D viewport (surface B/D) --- +constexpr const char* View2dBg = "view2d.bg"; + +// --- 3D viewport (surface D) --- +constexpr const char* View3dClear = "view3d.clear"; +constexpr const char* View3dGrid = "view3d.grid"; + +// --- curve editor (surface B) --- +constexpr const char* CurveBg = "curve.bg"; +constexpr const char* CurveGrid = "curve.grid"; +constexpr const char* CurveIdentity = "curve.identity"; +constexpr const char* CurveLine = "curve.line"; +constexpr const char* CurveAnchor = "curve.anchor"; +constexpr const char* CurveAnchorHover = "curve.anchor.hover"; +constexpr const char* CurveAnchorSelect = "curve.anchor.select"; +constexpr const char* CurveHandleLine = "curve.handle.line"; +constexpr const char* CurveHandleDot = "curve.handle.dot"; +constexpr const char* CurveHandleHover = "curve.handle.hover"; +constexpr const char* CurveHandleCorner = "curve.handle.corner"; + +} // namespace Tokens diff --git a/src/viewer3d/CMakeLists.txt b/src/viewer3d/CMakeLists.txt index 958296cd..dddc814d 100644 --- a/src/viewer3d/CMakeLists.txt +++ b/src/viewer3d/CMakeLists.txt @@ -39,11 +39,13 @@ set(RESOURCES # library add_library(viewer3d STATIC ${SRCS} ${HEADERS} ${RESOURCES}) -target_link_libraries(viewer3d PRIVATE Qt${QT_VERSION_MAJOR}::Core - Qt${QT_VERSION_MAJOR}::Gui - Qt${QT_VERSION_MAJOR}::OpenGLWidgets +target_link_libraries(viewer3d PRIVATE Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Gui + Qt${QT_VERSION_MAJOR}::OpenGLWidgets Qt${QT_VERSION_MAJOR}::Widgets - OpenGL::GL) + OpenGL::GL + theme) +target_include_directories(viewer3d PRIVATE ${CMAKE_SOURCE_DIR}/src/theme) set_target_properties(viewer3d PROPERTIES @@ -75,8 +77,10 @@ add_executable(viewer3d_app ) target_link_libraries(viewer3d_app PRIVATE Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Gui - Qt${QT_VERSION_MAJOR}::OpenGLWidgets - Qt${QT_VERSION_MAJOR}::Widgets) + Qt${QT_VERSION_MAJOR}::OpenGLWidgets + Qt${QT_VERSION_MAJOR}::Widgets + theme) +target_include_directories(viewer3d_app PRIVATE ${CMAKE_SOURCE_DIR}/src/theme) set_target_properties(viewer3d_app PROPERTIES diff --git a/src/viewer3d/assets/material_info.glsl b/src/viewer3d/assets/material_info.glsl index 86a379a1..9b867326 100644 --- a/src/viewer3d/assets/material_info.glsl +++ b/src/viewer3d/assets/material_info.glsl @@ -219,6 +219,11 @@ vec4 getBaseColor() //baseColor *= baseColorMap; #endif +#ifdef HAS_ALPHA_MAP + // Separate grayscale alpha/opacity map, independent of the base color map's own alpha. + baseColor.a *= texture(u_AlphaSampler, getAlphaUV()).r; +#endif + return baseColor * getVertexColor(); } diff --git a/src/viewer3d/assets/skybox.vert b/src/viewer3d/assets/skybox.vert index 9b467787..e63b48ee 100644 --- a/src/viewer3d/assets/skybox.vert +++ b/src/viewer3d/assets/skybox.vert @@ -7,10 +7,13 @@ out vec3 v_texCoord; uniform mat4 u_modelMatrix; uniform mat4 u_viewMatrix; uniform mat4 u_projectionMatrix; +// Yaw about the up axis, matching u_EnvRotation in the pbr shader so the +// background and the image based lighting stay in sync. +uniform mat3 u_envRotation; void main() { - v_texCoord = a_position; + v_texCoord = u_envRotation * a_position; // Remove translation from view matrix mat4 rotView = mat4(mat3(u_viewMatrix)); diff --git a/src/viewer3d/assets/textures.glsl b/src/viewer3d/assets/textures.glsl index 54d8b7b0..09649c0c 100644 --- a/src/viewer3d/assets/textures.glsl +++ b/src/viewer3d/assets/textures.glsl @@ -91,6 +91,10 @@ uniform sampler2D u_RoughnessSampler; uniform int u_RoughnessUVSet; uniform mat3 u_RoughnessUVTransform; +uniform sampler2D u_AlphaSampler; +uniform int u_AlphaUVSet; +uniform mat3 u_AlphaUVTransform; + vec2 getBaseColorUV() { vec3 uv = vec3(u_BaseColorUVSet < 1 ? v_texcoord_0 : v_texcoord_1, 1.0); @@ -135,6 +139,17 @@ vec2 getRoughnessUV() return uv.xy; } +vec2 getAlphaUV() +{ + vec3 uv = vec3(u_AlphaUVSet < 1 ? v_texcoord_0 : v_texcoord_1, 1.0); + +#ifdef HAS_ALPHA_UV_TRANSFORM + uv = u_AlphaUVTransform * uv; +#endif + + return uv.xy; +} + #endif diff --git a/src/viewer3d/geometry/cube.cpp b/src/viewer3d/geometry/cube.cpp index 0a924eab..ff3fef8e 100644 --- a/src/viewer3d/geometry/cube.cpp +++ b/src/viewer3d/geometry/cube.cpp @@ -3,186 +3,155 @@ #include #include #include -#include #include -#include +#include #define BUFFER_OFFSET(i) ((char*)NULL + (i)) Mesh* createCube(QOpenGLFunctions* gl, float width, float height, float depth, int widthSegments, int heightSegments, int depthSegments) { - widthSegments = std::max(1, widthSegments); + widthSegments = std::max(1, widthSegments); heightSegments = std::max(1, heightSegments); - depthSegments = std::max(1, depthSegments); + depthSegments = std::max(1, depthSegments); + + // Interleaved layout per vertex: pos(3) normal(3) uv(2) tangent(4) = 12 floats + static constexpr int kFloatsPerVertex = 12; + static constexpr int kStride = kFloatsPerVertex * sizeof(float); + + const int wS = widthSegments, hS = heightSegments, dS = depthSegments; + const int totalVertices = 2 * ((dS+1)*(hS+1) + (wS+1)*(dS+1) + (wS+1)*(hS+1)); + const int totalIndices = 12 * (dS*hS + wS*dS + wS*hS); + + std::vector interleaved; + std::vector indices; + interleaved.reserve(totalVertices * kFloatsPerVertex); + indices.reserve(totalIndices); + + int vertexOffset = 0; + + // u, v, w are axis indices (0=x,1=y,2=z) that map the face's local axes + // onto world space. udir/vdir flip the winding. depth is the face offset. + auto buildPlane = [&](int u, int v, int w, int udir, int vdir, + float faceW, float faceH, float faceD, + int gridX, int gridY) + { + const float segW = faceW / gridX; + const float segH = faceH / gridY; + const float halfW = faceW / 2.0f; + const float halfH = faceH / 2.0f; + const float halfD = faceD / 2.0f; + const int gridX1 = gridX + 1; + const int gridY1 = gridY + 1; + + // Normal and tangent are constant across the face — compute once. + float norm[3] = {}; + norm[w] = faceD > 0.0f ? 1.0f : -1.0f; + + float tang[3] = {}; + tang[u] = (float)udir; - // buffers - QVector indices; - QVector vertices; - QVector normals; - QVector tangents; - QVector uvs; - - int vertexCount = 0; - - auto buildPlane = [&](int u, int v, int w, int udir, int vdir, float width, - float height, float depth, int gridX, int gridY) { - float segmentWidth = width / gridX; - float segmentHeight = height / gridY; - - float widthHalf = width / 2.0f; - float heightHalf = height / 2.0f; - float depthHalf = depth / 2.0f; - - int gridX1 = gridX + 1; - int gridY1 = gridY + 1; - - int offset = vertexCount; - - QVector3D vec; - - // Generate vertices for (int iy = 0; iy < gridY1; iy++) { - float y = iy * segmentHeight - heightHalf; + const float y = iy * segH - halfH; for (int ix = 0; ix < gridX1; ix++) { - float x = ix * segmentWidth - widthHalf; - - // Set vertex position - vec[u] = x * udir; - vec[v] = y * vdir; - vec[w] = depthHalf; - - vertices.append(vec.x()); - vertices.append(vec.y()); - vertices.append(vec.z()); - - // Set normal - vec[u] = 0; - vec[v] = 0; - vec[w] = depth > 0 ? 1 : -1; - - normals.append(vec.x()); - normals.append(vec.y()); - normals.append(vec.z()); - - // Set tangent - QVector3D tangentVec; - tangentVec[u] = udir; - tangentVec[v] = 0; - tangentVec[w] = 0; - - tangents.append(tangentVec.x()); - tangents.append(tangentVec.y()); - tangents.append(tangentVec.z()); - tangents.append(1.0f); - - // Set UV - uvs.append(ix / (float)gridX); - uvs.append(1.0f - (iy / (float)gridY)); - - vertexCount++; + const float x = ix * segW - halfW; + + // position + float pos[3] = {}; + pos[u] = x * udir; + pos[v] = y * vdir; + pos[w] = halfD; + interleaved.push_back(pos[0]); + interleaved.push_back(pos[1]); + interleaved.push_back(pos[2]); + + // normal + interleaved.push_back(norm[0]); + interleaved.push_back(norm[1]); + interleaved.push_back(norm[2]); + + // uv + interleaved.push_back(ix / (float)gridX); + interleaved.push_back(1.0f - (iy / (float)gridY)); + + // tangent + interleaved.push_back(tang[0]); + interleaved.push_back(tang[1]); + interleaved.push_back(tang[2]); + interleaved.push_back(1.0f); } } - // Generate indices for (int iy = 0; iy < gridY; iy++) { for (int ix = 0; ix < gridX; ix++) { - unsigned int a = offset + ix + gridX1 * iy; - unsigned int b = offset + ix + gridX1 * (iy + 1); - unsigned int c = offset + (ix + 1) + gridX1 * (iy + 1); - unsigned int d = offset + (ix + 1) + gridX1 * iy; - - // Two triangles per quad - indices.append(a); - indices.append(b); - indices.append(d); - - indices.append(b); - indices.append(c); - indices.append(d); + const unsigned int a = vertexOffset + ix + gridX1 * iy; + const unsigned int b = vertexOffset + ix + gridX1 * (iy + 1); + const unsigned int c = vertexOffset + (ix + 1) + gridX1 * (iy + 1); + const unsigned int d = vertexOffset + (ix + 1) + gridX1 * iy; + + indices.push_back(a); + indices.push_back(b); + indices.push_back(d); + + indices.push_back(b); + indices.push_back(c); + indices.push_back(d); } } + + vertexOffset += gridX1 * gridY1; }; - // Build all 6 faces of the cube - buildPlane(2, 1, 0, -1, -1, depth, height, width, depthSegments, - heightSegments); // px - buildPlane(2, 1, 0, 1, -1, depth, height, -width, depthSegments, - heightSegments); // nx - buildPlane(0, 2, 1, 1, 1, width, depth, height, widthSegments, - depthSegments); // py - buildPlane(0, 2, 1, 1, -1, width, depth, -height, widthSegments, - depthSegments); // ny - buildPlane(0, 1, 2, 1, -1, width, height, depth, widthSegments, - heightSegments); // pz - buildPlane(0, 1, 2, -1, -1, width, height, -depth, widthSegments, - heightSegments); // nz - - // Build OpenGL buffers + buildPlane(2, 1, 0, -1, -1, depth, height, width, depthSegments, heightSegments); // px + buildPlane(2, 1, 0, 1, -1, depth, height, -width, depthSegments, heightSegments); // nx + buildPlane(0, 2, 1, 1, 1, width, depth, height, widthSegments, depthSegments); // py + buildPlane(0, 2, 1, 1, -1, width, depth, -height, widthSegments, depthSegments); // ny + buildPlane(0, 1, 2, 1, -1, width, height, depth, widthSegments, heightSegments); // pz + buildPlane(0, 1, 2, -1, -1, width, height, -depth, widthSegments, heightSegments); // nz + QOpenGLVertexArrayObject* vao = new QOpenGLVertexArrayObject(); vao->create(); vao->bind(); - QOpenGLBuffer* vbo; - - // Position buffer - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); + auto vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); vbo->create(); vbo->bind(); vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(vertices.data(), vertices.length() * sizeof(float)); + vbo->allocate(interleaved.data(), (int)(interleaved.size() * sizeof(float))); + gl->glEnableVertexAttribArray((int)VertexUsage::Position); gl->glVertexAttribPointer((int)VertexUsage::Position, 3, GL_FLOAT, GL_FALSE, - 3 * sizeof(float), BUFFER_OFFSET(0)); + kStride, BUFFER_OFFSET(0)); - // Normal buffer - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); - vbo->create(); - vbo->bind(); - vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(normals.data(), normals.length() * sizeof(float)); gl->glEnableVertexAttribArray((int)VertexUsage::Normal); gl->glVertexAttribPointer((int)VertexUsage::Normal, 3, GL_FLOAT, GL_FALSE, - 3 * sizeof(float), BUFFER_OFFSET(0)); + kStride, BUFFER_OFFSET(3 * sizeof(float))); - // UV buffer - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); - vbo->create(); - vbo->bind(); - vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(uvs.data(), uvs.length() * sizeof(float)); gl->glEnableVertexAttribArray((int)VertexUsage::TexCoord0); - gl->glVertexAttribPointer((int)VertexUsage::TexCoord0, 2, GL_FLOAT, - GL_FALSE, 2 * sizeof(float), BUFFER_OFFSET(0)); + gl->glVertexAttribPointer((int)VertexUsage::TexCoord0, 2, GL_FLOAT, GL_FALSE, + kStride, BUFFER_OFFSET(6 * sizeof(float))); - // Tangent buffer - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); - vbo->create(); - vbo->bind(); - vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(tangents.data(), tangents.length() * sizeof(float)); gl->glEnableVertexAttribArray((int)VertexUsage::Tangent); gl->glVertexAttribPointer((int)VertexUsage::Tangent, 4, GL_FLOAT, GL_FALSE, - 4 * sizeof(float), BUFFER_OFFSET(0)); + kStride, BUFFER_OFFSET(8 * sizeof(float))); vao->release(); - // Index buffer auto ibo = new QOpenGLBuffer(QOpenGLBuffer::IndexBuffer); ibo->create(); ibo->bind(); ibo->setUsagePattern(QOpenGLBuffer::StaticDraw); - ibo->allocate(indices.data(), indices.length() * sizeof(unsigned int)); + ibo->allocate(indices.data(), (int)(indices.size() * sizeof(unsigned int))); auto mesh = new Mesh(); - mesh->vao = vao; - mesh->meshType = MeshType::Generated; - mesh->indexBuffer = ibo; - mesh->numElements = indices.count(); + mesh->vao = vao; + mesh->meshType = MeshType::Generated; + mesh->indexBuffer = ibo; + mesh->numElements = (int)indices.size(); mesh->indexByteOffset = 0; - mesh->indexType = GL_UNSIGNED_INT; - mesh->primitiveMode = GL_TRIANGLES; + mesh->indexType = GL_UNSIGNED_INT; + mesh->primitiveMode = GL_TRIANGLES; return mesh; } diff --git a/src/viewer3d/geometry/cylinder.cpp b/src/viewer3d/geometry/cylinder.cpp index 14afe4b8..1f4adac5 100644 --- a/src/viewer3d/geometry/cylinder.cpp +++ b/src/viewer3d/geometry/cylinder.cpp @@ -3,285 +3,170 @@ #include #include #include -#include #include -#include +#include +#include #define BUFFER_OFFSET(i) ((char*)NULL + (i)) Mesh* createCylinder(QOpenGLFunctions* gl, float radiusTop, float radiusBottom, float height, int radialSegments, int heightSegments, - bool openEnded) + float bevelRadius, int bevelSegments, + float uvScaleU, float uvScaleV) { radialSegments = std::max(3, radialSegments); heightSegments = std::max(1, heightSegments); + bevelSegments = std::max(1, bevelSegments); + bevelRadius = std::min({bevelRadius, radiusTop, radiusBottom, height / 2.0f}); + + // Interleaved layout per vertex: pos(3) normal(3) uv(2) tangent(4) = 12 floats + static constexpr int kFloatsPerVertex = 12; + static constexpr int kStride = kFloatsPerVertex * sizeof(float); + + // Ring layout top→bottom: + // top bevel: bevelSegments+1 rings (0 .. bevelSegments) + // torso: heightSegments rings (bevelSegments+1 .. bevelSegments+heightSegments) + // bottom bevel: bevelSegments rings (bevelSegments+heightSegments+1 .. 2*bevelSegments+heightSegments) + // Junction rings are owned by the preceding section; the next section skips ring 0. + const int totalRings = 2 * bevelSegments + heightSegments + 1; + const int colCount = radialSegments + 1; + const int totalVertices = totalRings * colCount; + const int totalIndices = (totalRings - 1) * radialSegments * 6; + + std::vector interleaved; + std::vector indices; + interleaved.reserve(totalVertices * kFloatsPerVertex); + indices.reserve(totalIndices); + + // Precompute sin/cos per column — reused by all sections. + std::vector sinT(colCount), cosT(colCount); + for (int x = 0; x < colCount; x++) { + const float theta = (x / (float)radialSegments) * (float)(M_PI * 2.0); + sinT[x] = std::sin(theta); + cosT[x] = std::cos(theta); + } - // buffers - QVector indices; - QVector vertices; - QVector normals; - QVector tangents; - QVector uvs; - - int index = 0; - QVector> indexArray; - - float halfHeight = height / 2.0f; - - // Generate torso - for (int y = 0; y <= heightSegments; y++) { - QVector indexRow; - - float v = y / (float)heightSegments; - float radius = v * (radiusBottom - radiusTop) + radiusTop; - - for (int x = 0; x <= radialSegments; x++) { - float u = x / (float)radialSegments; - float theta = u * M_PI * 2.0f; - - float sinTheta = std::sin(theta); - float cosTheta = std::cos(theta); + const float halfHeight = height / 2.0f; - // Vertex position - float vx = radius * sinTheta; - float vy = -v * height + halfHeight; - float vz = radius * cosTheta; + // Slope for the torso normal (constant; 0 for a true cylinder). + const float slope = std::atan2(radiusBottom - radiusTop, height); + const float cosSlope = std::cos(slope); + const float sinSlope = std::sin(slope); - vertices.append(vx); - vertices.append(vy); - vertices.append(vz); + // Push one full ring. normR = outward radial scale, normY = vertical normal component. + int ringIndex = 0; + auto pushRing = [&](float r, float y, float normR, float normY) { + const float globalV = ringIndex / (float)(totalRings - 1); + for (int x = 0; x < colCount; x++) { + const float s = sinT[x], c = cosT[x]; - // Normal (accounting for cone slope) - float slope = std::atan2(radiusBottom - radiusTop, height); - QVector3D normal(sinTheta * std::cos(slope), std::sin(slope), - cosTheta * std::cos(slope)); - normal.normalize(); - normals.append(normal.x()); - normals.append(normal.y()); - normals.append(normal.z()); + interleaved.push_back(r * s); + interleaved.push_back(y); + interleaved.push_back(r * c); - // Tangent (perpendicular to normal, going around the cylinder) - QVector3D tangent(cosTheta, 0.0f, -sinTheta); - tangent.normalize(); - tangents.append(tangent.x()); - tangents.append(tangent.y()); - tangents.append(tangent.z()); - tangents.append(1.0f); + interleaved.push_back(normR * s); + interleaved.push_back(normY); + interleaved.push_back(normR * c); - // UV - uvs.append(u * 2.0f); - uvs.append(1.0f - v); + interleaved.push_back((x / (float)radialSegments) * uvScaleU); + interleaved.push_back((1.0f - globalV) * uvScaleV); - indexRow.append(index++); + interleaved.push_back(c); + interleaved.push_back(0.0f); + interleaved.push_back(-s); + interleaved.push_back(1.0f); } - - indexArray.append(indexRow); + ringIndex++; + }; + + // ---- Top bevel (rings 0..bevelSegments) ---- + // phi=PI/2 → top rim; phi=0 → torso junction. + for (int i = 0; i <= bevelSegments; i++) { + const float phi = (float)(bevelSegments - i) / bevelSegments * (float)(M_PI / 2.0); + const float r = (radiusTop - bevelRadius) + bevelRadius * std::cos(phi); + const float y = (halfHeight - bevelRadius) + bevelRadius * std::sin(phi); + pushRing(r, y, std::cos(phi), std::sin(phi)); } - // Generate indices for torso - for (int y = 0; y < heightSegments; y++) { - for (int x = 0; x < radialSegments; x++) { - unsigned int a = indexArray[y][x]; - unsigned int b = indexArray[y + 1][x]; - unsigned int c = indexArray[y + 1][x + 1]; - unsigned int d = indexArray[y][x + 1]; - - indices.append(a); - indices.append(b); - indices.append(d); - - indices.append(b); - indices.append(c); - indices.append(d); - } + // ---- Torso (rings bevelSegments+1..bevelSegments+heightSegments) ---- + // Skip j=0 — that junction ring was already pushed by the top bevel. + for (int j = 1; j <= heightSegments; j++) { + const float v = j / (float)heightSegments; + const float r = v * (radiusBottom - radiusTop) + radiusTop; + const float y = (halfHeight - bevelRadius) - v * (height - 2.0f * bevelRadius); + pushRing(r, y, cosSlope, sinSlope); } - // Generate top cap - if (!openEnded && radiusTop > 0) { - unsigned int centerIndex = index; - - // Center vertex - vertices.append(0.0f); - vertices.append(halfHeight); - vertices.append(0.0f); - - normals.append(0.0f); - normals.append(-1.0f); - normals.append(0.0f); - - tangents.append(1.0f); - tangents.append(0.0f); - tangents.append(0.0f); - tangents.append(1.0f); - - uvs.append(0.5f); - uvs.append(0.5f); - - index++; - - // Ring vertices - for (int x = 0; x <= radialSegments; x++) { - float u = x / (float)radialSegments; - float theta = u * M_PI * 2.0f; - - float sinTheta = std::sin(theta); - float cosTheta = std::cos(theta); - - vertices.append(radiusTop * sinTheta); - vertices.append(halfHeight); - vertices.append(radiusTop * cosTheta); - - normals.append(0.0f); - normals.append(-1.0f); - normals.append(0.0f); - - tangents.append(1.0f); - tangents.append(0.0f); - tangents.append(0.0f); - tangents.append(1.0f); - - uvs.append((cosTheta * 0.5f) + 0.5f); - uvs.append((sinTheta * 0.5f) + 0.5f); - - index++; - } - - // Generate top cap indices - for (int x = 0; x < radialSegments; x++) { - unsigned int c = centerIndex + x + 1; - unsigned int d = centerIndex + x + 2; - - indices.append(d); - indices.append(c); - indices.append(centerIndex); - } + // ---- Bottom bevel (rings bevelSegments+heightSegments+1..2*bevelSegments+heightSegments) ---- + // Skip k=0 — that junction ring was already pushed by the torso. + // phi=0 → torso junction; phi=PI/2 → bottom rim. + for (int k = 1; k <= bevelSegments; k++) { + const float phi = (float)k / bevelSegments * (float)(M_PI / 2.0); + const float r = (radiusBottom - bevelRadius) + bevelRadius * std::cos(phi); + const float y = -(halfHeight - bevelRadius) - bevelRadius * std::sin(phi); + pushRing(r, y, std::cos(phi), -std::sin(phi)); } - // Generate bottom cap - if (!openEnded && radiusBottom > 0) { - unsigned int centerIndex = index; - - // Center vertex - vertices.append(0.0f); - vertices.append(-halfHeight); - vertices.append(0.0f); - - normals.append(0.0f); - normals.append(1.0f); - normals.append(0.0f); - - tangents.append(1.0f); - tangents.append(0.0f); - tangents.append(0.0f); - tangents.append(1.0f); - - uvs.append(0.5f); - uvs.append(0.5f); - - index++; - - // Ring vertices - for (int x = 0; x <= radialSegments; x++) { - float u = x / (float)radialSegments; - float theta = u * M_PI * 2.0f; - - float sinTheta = std::sin(theta); - float cosTheta = std::cos(theta); - - vertices.append(radiusBottom * sinTheta); - vertices.append(-halfHeight); - vertices.append(radiusBottom * cosTheta); - - normals.append(0.0f); - normals.append(1.0f); - normals.append(0.0f); - - tangents.append(1.0f); - tangents.append(0.0f); - tangents.append(0.0f); - tangents.append(1.0f); - - uvs.append((cosTheta * 0.5f) + 0.5f); - uvs.append((sinTheta * 0.5f) + 0.5f); - - index++; - } - - // Generate bottom cap indices + // ---- Indices: connect every adjacent pair of rings ---- + for (int r = 0; r < totalRings - 1; r++) { for (int x = 0; x < radialSegments; x++) { - unsigned int c = centerIndex + x + 1; - unsigned int d = centerIndex + x + 2; - - indices.append(centerIndex); - indices.append(c); - indices.append(d); + const unsigned int a = r * colCount + x; + const unsigned int b = (r + 1) * colCount + x; + const unsigned int c = (r + 1) * colCount + x + 1; + const unsigned int d = r * colCount + x + 1; + + indices.push_back(a); + indices.push_back(b); + indices.push_back(d); + + indices.push_back(b); + indices.push_back(c); + indices.push_back(d); } } - // Build OpenGL buffers QOpenGLVertexArrayObject* vao = new QOpenGLVertexArrayObject(); vao->create(); vao->bind(); - QOpenGLBuffer* vbo; - - // Position buffer - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); + auto vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); vbo->create(); vbo->bind(); vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(vertices.data(), vertices.length() * sizeof(float)); + vbo->allocate(interleaved.data(), (int)(interleaved.size() * sizeof(float))); + gl->glEnableVertexAttribArray((int)VertexUsage::Position); gl->glVertexAttribPointer((int)VertexUsage::Position, 3, GL_FLOAT, GL_FALSE, - 3 * sizeof(float), BUFFER_OFFSET(0)); + kStride, BUFFER_OFFSET(0)); - // Normal buffer - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); - vbo->create(); - vbo->bind(); - vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(normals.data(), normals.length() * sizeof(float)); gl->glEnableVertexAttribArray((int)VertexUsage::Normal); gl->glVertexAttribPointer((int)VertexUsage::Normal, 3, GL_FLOAT, GL_FALSE, - 3 * sizeof(float), BUFFER_OFFSET(0)); + kStride, BUFFER_OFFSET(3 * sizeof(float))); - // UV buffer - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); - vbo->create(); - vbo->bind(); - vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(uvs.data(), uvs.length() * sizeof(float)); gl->glEnableVertexAttribArray((int)VertexUsage::TexCoord0); - gl->glVertexAttribPointer((int)VertexUsage::TexCoord0, 2, GL_FLOAT, - GL_FALSE, 2 * sizeof(float), BUFFER_OFFSET(0)); + gl->glVertexAttribPointer((int)VertexUsage::TexCoord0, 2, GL_FLOAT, GL_FALSE, + kStride, BUFFER_OFFSET(6 * sizeof(float))); - // Tangent buffer - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); - vbo->create(); - vbo->bind(); - vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(tangents.data(), tangents.length() * sizeof(float)); gl->glEnableVertexAttribArray((int)VertexUsage::Tangent); gl->glVertexAttribPointer((int)VertexUsage::Tangent, 4, GL_FLOAT, GL_FALSE, - 4 * sizeof(float), BUFFER_OFFSET(0)); + kStride, BUFFER_OFFSET(8 * sizeof(float))); vao->release(); - // Index buffer auto ibo = new QOpenGLBuffer(QOpenGLBuffer::IndexBuffer); ibo->create(); ibo->bind(); ibo->setUsagePattern(QOpenGLBuffer::StaticDraw); - ibo->allocate(indices.data(), indices.length() * sizeof(unsigned int)); + ibo->allocate(indices.data(), (int)(indices.size() * sizeof(unsigned int))); auto mesh = new Mesh(); - mesh->vao = vao; - mesh->meshType = MeshType::Generated; - mesh->indexBuffer = ibo; - mesh->numElements = indices.count(); + mesh->vao = vao; + mesh->meshType = MeshType::Generated; + mesh->indexBuffer = ibo; + mesh->numElements = (int)indices.size(); mesh->indexByteOffset = 0; - mesh->indexType = GL_UNSIGNED_INT; - mesh->primitiveMode = GL_TRIANGLES; + mesh->indexType = GL_UNSIGNED_INT; + mesh->primitiveMode = GL_TRIANGLES; return mesh; } diff --git a/src/viewer3d/geometry/geometry.h b/src/viewer3d/geometry/geometry.h index 97c85985..6abc3296 100644 --- a/src/viewer3d/geometry/geometry.h +++ b/src/viewer3d/geometry/geometry.h @@ -27,7 +27,8 @@ Mesh* createPlane(QOpenGLFunctions* gl, float width = 1, float height = 1, Mesh* createCylinder(QOpenGLFunctions* gl, float radiusTop = 1, float radiusBottom = 1, float height = 1, int radialSegments = 32, int heightSegments = 1, - bool openEnded = false); + float bevelRadius = 0.1f, int bevelSegments = 4, + float uvScaleU = 3.0f, float uvScaleV = 1.0f); // Create a subdivided cube mesh with normals, tangents, and UVs // https://github.com/mrdoob/three.js/blob/master/src/geometries/BoxGeometry.js diff --git a/src/viewer3d/geometry/plane.cpp b/src/viewer3d/geometry/plane.cpp index 96eee2fe..b254592d 100644 --- a/src/viewer3d/geometry/plane.cpp +++ b/src/viewer3d/geometry/plane.cpp @@ -4,7 +4,7 @@ #include #include #include -#include +#include #define BUFFER_OFFSET(i) ((char*)NULL + (i)) @@ -12,175 +12,146 @@ Mesh* createPlane(QOpenGLFunctions* gl, float width, float height, int widthSegments, int heightSegments, PlaneOrientation orientation) { - widthSegments = std::max(1, widthSegments); + widthSegments = std::max(1, widthSegments); heightSegments = std::max(1, heightSegments); - float width_half = width / 2.0f; - float height_half = height / 2.0f; + const float width_half = width / 2.0f; + const float height_half = height / 2.0f; - int gridX = widthSegments; - int gridY = heightSegments; + const int gridX1 = widthSegments + 1; + const int gridY1 = heightSegments + 1; - int gridX1 = gridX + 1; - int gridY1 = gridY + 1; + const float segment_width = width / widthSegments; + const float segment_height = height / heightSegments; - float segment_width = width / gridX; - float segment_height = height / gridY; + const int vertexCount = gridX1 * gridY1; - // buffers - QVector indices; - QVector vertices; - QVector normals; - QVector tangents; - QVector uvs; + // Interleaved layout per vertex: pos(3) normal(3) uv(2) tangent(4) = 12 floats + static constexpr int kFloatsPerVertex = 12; + static constexpr int kStride = kFloatsPerVertex * sizeof(float); + + std::vector interleaved; + interleaved.reserve(vertexCount * kFloatsPerVertex); + + std::vector indices; + indices.reserve(widthSegments * heightSegments * 6); + + // Precompute orientation-dependent constants to keep the inner loop branch-free. + float nx, ny, nz; + float tx, ty, tz; + const bool flipV = (orientation != PlaneOrientation::XZ); + + if (orientation == PlaneOrientation::XY) { + nx = 0.0f; ny = 0.0f; nz = -1.0f; + tx = 1.0f; ty = 0.0f; tz = 0.0f; + } else if (orientation == PlaneOrientation::YZ) { + nx = -1.0f; ny = 0.0f; nz = 0.0f; + tx = 0.0f; ty = 0.0f; tz = 1.0f; + } else { // XZ + nx = 0.0f; ny = 1.0f; nz = 0.0f; + tx = 1.0f; ty = 0.0f; tz = 0.0f; + } - // Generate vertices, normals, uvs for (int iy = 0; iy < gridY1; iy++) { - float v = iy * segment_height - height_half; + const float v = iy * segment_height - height_half; for (int ix = 0; ix < gridX1; ix++) { - float u = ix * segment_width - width_half; + const float u = ix * segment_width - width_half; + // position if (orientation == PlaneOrientation::XY) { - // XY plane, normal facing -Z - vertices.append(u); - vertices.append(-v); - vertices.append(0.0f); - - normals.append(0.0f); - normals.append(0.0f); - normals.append(-1.0f); - - tangents.append(1.0f); - tangents.append(0.0f); - tangents.append(0.0f); - tangents.append(1.0f); - } - else if (orientation == PlaneOrientation::YZ) { - // YZ plane, normal facing -X - vertices.append(0.0f); - vertices.append(-v); - vertices.append(u); - - normals.append(-1.0f); - normals.append(0.0f); - normals.append(0.0f); - - tangents.append(0.0f); - tangents.append(0.0f); - tangents.append(1.0f); - tangents.append(1.0f); - } - else { // PlaneOrientation::XZ - // XZ plane, normal facing +Y - vertices.append(u); - vertices.append(0.0f); - vertices.append(v); - - normals.append(0.0f); - normals.append(1.0f); - normals.append(0.0f); - - tangents.append(1.0f); - tangents.append(0.0f); - tangents.append(0.0f); - tangents.append(1.0f); + interleaved.push_back(u); + interleaved.push_back(-v); + interleaved.push_back(0.0f); + } else if (orientation == PlaneOrientation::YZ) { + interleaved.push_back(0.0f); + interleaved.push_back(-v); + interleaved.push_back(u); + } else { + interleaved.push_back(u); + interleaved.push_back(0.0f); + interleaved.push_back(v); } - // UV coordinates - uvs.append(ix / (float)gridX); - if (orientation == PlaneOrientation::XZ) { - uvs.append(iy / (float)gridY); - } - else { - uvs.append(1.0f - (iy / (float)gridY)); - } + // normal + interleaved.push_back(nx); + interleaved.push_back(ny); + interleaved.push_back(nz); + + // uv + const float uvx = ix / (float)widthSegments; + const float uvy = flipV ? 1.0f - (iy / (float)heightSegments) + : iy / (float)heightSegments; + interleaved.push_back(uvx); + interleaved.push_back(uvy); + + // tangent + interleaved.push_back(tx); + interleaved.push_back(ty); + interleaved.push_back(tz); + interleaved.push_back(1.0f); } } - // Generate indices - for (int iy = 0; iy < gridY; iy++) { - for (int ix = 0; ix < gridX; ix++) { - unsigned int a = ix + gridX1 * iy; - unsigned int b = ix + gridX1 * (iy + 1); - unsigned int c = (ix + 1) + gridX1 * (iy + 1); - unsigned int d = (ix + 1) + gridX1 * iy; - - // Two triangles per quad - indices.append(a); - indices.append(b); - indices.append(d); - - indices.append(b); - indices.append(c); - indices.append(d); + for (int iy = 0; iy < heightSegments; iy++) { + for (int ix = 0; ix < widthSegments; ix++) { + const unsigned int a = ix + gridX1 * iy; + const unsigned int b = ix + gridX1 * (iy + 1); + const unsigned int c = (ix + 1) + gridX1 * (iy + 1); + const unsigned int d = (ix + 1) + gridX1 * iy; + + indices.push_back(a); + indices.push_back(b); + indices.push_back(d); + + indices.push_back(b); + indices.push_back(c); + indices.push_back(d); } } - // Build OpenGL buffers QOpenGLVertexArrayObject* vao = new QOpenGLVertexArrayObject(); vao->create(); vao->bind(); - QOpenGLBuffer* vbo; - - // Position buffer - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); + auto vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); vbo->create(); vbo->bind(); vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(vertices.data(), vertices.length() * sizeof(float)); + vbo->allocate(interleaved.data(), (int)(interleaved.size() * sizeof(float))); + gl->glEnableVertexAttribArray((int)VertexUsage::Position); gl->glVertexAttribPointer((int)VertexUsage::Position, 3, GL_FLOAT, GL_FALSE, - 3 * sizeof(float), BUFFER_OFFSET(0)); + kStride, BUFFER_OFFSET(0)); - // Normal buffer - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); - vbo->create(); - vbo->bind(); - vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(normals.data(), normals.length() * sizeof(float)); gl->glEnableVertexAttribArray((int)VertexUsage::Normal); gl->glVertexAttribPointer((int)VertexUsage::Normal, 3, GL_FLOAT, GL_FALSE, - 3 * sizeof(float), BUFFER_OFFSET(0)); + kStride, BUFFER_OFFSET(3 * sizeof(float))); - // UV buffer - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); - vbo->create(); - vbo->bind(); - vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(uvs.data(), uvs.length() * sizeof(float)); gl->glEnableVertexAttribArray((int)VertexUsage::TexCoord0); - gl->glVertexAttribPointer((int)VertexUsage::TexCoord0, 2, GL_FLOAT, - GL_FALSE, 2 * sizeof(float), BUFFER_OFFSET(0)); + gl->glVertexAttribPointer((int)VertexUsage::TexCoord0, 2, GL_FLOAT, GL_FALSE, + kStride, BUFFER_OFFSET(6 * sizeof(float))); - // Tangent buffer - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); - vbo->create(); - vbo->bind(); - vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(tangents.data(), tangents.length() * sizeof(float)); gl->glEnableVertexAttribArray((int)VertexUsage::Tangent); gl->glVertexAttribPointer((int)VertexUsage::Tangent, 4, GL_FLOAT, GL_FALSE, - 4 * sizeof(float), BUFFER_OFFSET(0)); + kStride, BUFFER_OFFSET(8 * sizeof(float))); vao->release(); - // Index buffer auto ibo = new QOpenGLBuffer(QOpenGLBuffer::IndexBuffer); ibo->create(); ibo->bind(); ibo->setUsagePattern(QOpenGLBuffer::StaticDraw); - ibo->allocate(indices.data(), indices.length() * sizeof(unsigned int)); + ibo->allocate(indices.data(), (int)(indices.size() * sizeof(unsigned int))); auto mesh = new Mesh(); - mesh->vao = vao; - mesh->meshType = MeshType::Generated; - mesh->indexBuffer = ibo; - mesh->numElements = indices.count(); + mesh->vao = vao; + mesh->meshType = MeshType::Generated; + mesh->indexBuffer = ibo; + mesh->numElements = (int)indices.size(); mesh->indexByteOffset = 0; - mesh->indexType = GL_UNSIGNED_INT; - mesh->primitiveMode = GL_TRIANGLES; + mesh->indexType = GL_UNSIGNED_INT; + mesh->primitiveMode = GL_TRIANGLES; return mesh; } diff --git a/src/viewer3d/geometry/sphere.cpp b/src/viewer3d/geometry/sphere.cpp index 26a7ff69..1d59e65a 100644 --- a/src/viewer3d/geometry/sphere.cpp +++ b/src/viewer3d/geometry/sphere.cpp @@ -4,7 +4,8 @@ #include #include #include -#include +#include +#include #define BUFFER_OFFSET(i) ((char*)NULL + (i)) @@ -12,155 +13,150 @@ Mesh* createSphere(QOpenGLFunctions* gl, float radius, int widthSegments, int heightSegments, float phiStart, float phiLength, float thetaStart, float thetaLength) { - const float uvScaleX = 2; - const float uvScaleY = 1; + const float uvScaleX = 2.0f; + const float uvScaleY = 1.0f; - widthSegments = std::max(3.0, std::floor(widthSegments)); - heightSegments = std::max(2.0, std::floor(heightSegments)); + widthSegments = std::max(3, widthSegments); + heightSegments = std::max(2, heightSegments); - auto thetaEnd = std::min((double)thetaStart + thetaLength, M_PI); + const double thetaEnd = std::min((double)thetaStart + thetaLength, M_PI); - int index = 0; - QVector> grid; + const int ringCount = heightSegments + 1; + const int colCount = widthSegments + 1; + const int vertexCount = ringCount * colCount; - QVector3D vertex; - - // buffers - QVector indices; - QVector vertices; - QVector normals; - QVector tangents; - QVector uvs; - - for (int iy = 0; iy <= heightSegments; iy++) { - QVector verticesRow; - - auto v = iy / (float)heightSegments; - - auto uOffset = 0; - if (iy == 0 && thetaStart == 0) { - - uOffset = 0.5 / widthSegments; - } - else if (iy == heightSegments && thetaEnd == M_PI) { - - uOffset = -0.5 / widthSegments; - } - - for (int ix = 0; ix <= widthSegments; ix++) { - - auto u = ix / (float)widthSegments; - - // vertex - auto phi = phiStart + u * phiLength; - auto theta = thetaStart + v * thetaLength; - - auto x = -radius * std::cos(phi) * std::sin(theta); - auto y = radius * std::cos(theta); - auto z = radius * std::sin(phi) * std::sin(theta); - - vertices.append({x, y, z}); - - // normal - QVector3D normal(x, y, z); - normal.normalize(); - normals.append({normal.x(), normal.y(), normal.z()}); - - // tangent (derivative of position with respect to phi) - QVector3D tangent(std::sin(phi), 0.0f, std::cos(phi)); - tangent.normalize(); - tangents.append({tangent.x(), tangent.y(), tangent.z(), 1.0f}); - - // uv - - uvs.append({(u + uOffset) * uvScaleX, (1 - v) * uvScaleY}); + // Precompute trig per column (phi) and per ring (theta) to avoid + // redundant sin/cos calls inside the double loop. + std::vector sinPhi(colCount), cosPhi(colCount); + for (int ix = 0; ix < colCount; ix++) { + float phi = phiStart + (ix / (float)widthSegments) * phiLength; + sinPhi[ix] = std::sin(phi); + cosPhi[ix] = std::cos(phi); + } + std::vector sinTheta(ringCount), cosTheta(ringCount); + for (int iy = 0; iy < ringCount; iy++) { + float theta = thetaStart + (iy / (float)heightSegments) * thetaLength; + sinTheta[iy] = std::sin(theta); + cosTheta[iy] = std::cos(theta); + } - verticesRow.append(index++); + // Interleaved layout per vertex: pos(3) normal(3) uv(2) tangent(4) = 12 floats + static constexpr int kFloatsPerVertex = 12; + static constexpr int kStride = kFloatsPerVertex * sizeof(float); + + std::vector interleaved; + interleaved.reserve(vertexCount * kFloatsPerVertex); + + // Upper-bound index count: 6 per quad + std::vector indices; + indices.reserve(widthSegments * heightSegments * 6); + + for (int iy = 0; iy < ringCount; iy++) { + const float v = iy / (float)heightSegments; + const float sinT = sinTheta[iy]; + const float cosT = cosTheta[iy]; + + float uOffset = 0.0f; + if (iy == 0 && thetaStart == 0.0f) + uOffset = 0.5f / widthSegments; + else if (iy == heightSegments && thetaEnd == M_PI) + uOffset = -0.5f / widthSegments; + + for (int ix = 0; ix < colCount; ix++) { + const float u = ix / (float)widthSegments; + const float sP = sinPhi[ix]; + const float cP = cosPhi[ix]; + + // Position + const float x = -radius * cP * sinT; + const float y = radius * cosT; + const float z = radius * sP * sinT; + + interleaved.push_back(x); + interleaved.push_back(y); + interleaved.push_back(z); + + // Normal = position / radius (already unit length for a sphere) + interleaved.push_back(x / radius); + interleaved.push_back(y / radius); + interleaved.push_back(z / radius); + + // UV + interleaved.push_back((u + uOffset) * uvScaleX); + interleaved.push_back((1.0f - v) * uvScaleY); + + // Tangent = d(pos)/d(phi) normalized = (sin(phi), 0, cos(phi), 1) + // Already unit length: sqrt(sin²+cos²) = 1, no normalize needed. + interleaved.push_back(sP); + interleaved.push_back(0.0f); + interleaved.push_back(cP); + interleaved.push_back(1.0f); } - - grid.append(verticesRow); } + // Build indices with flat grid math — no 2D intermediate vector needed. for (int iy = 0; iy < heightSegments; iy++) { - for (int ix = 0; ix < widthSegments; ix++) { - - auto a = grid[iy][ix + 1]; - auto b = grid[iy][ix]; - auto c = grid[iy + 1][ix]; - auto d = grid[iy + 1][ix + 1]; - - if (iy != 0 || thetaStart > 0) - indices.append({a, b, d}); - if (iy != heightSegments - 1 || thetaEnd < M_PI) - indices.append({b, c, d}); + const unsigned int a = iy * colCount + ix + 1; + const unsigned int b = iy * colCount + ix; + const unsigned int c = (iy + 1) * colCount + ix; + const unsigned int d = (iy + 1) * colCount + ix + 1; + + if (iy != 0 || thetaStart > 0.0f) { + indices.push_back(a); + indices.push_back(b); + indices.push_back(d); + } + if (iy != heightSegments - 1 || thetaEnd < M_PI) { + indices.push_back(b); + indices.push_back(c); + indices.push_back(d); + } } } - // build QOpenGLVertexArrayObject* vao = new QOpenGLVertexArrayObject(); vao->create(); vao->bind(); - QOpenGLBuffer* vbo; - - // position - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); + auto vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); vbo->create(); vbo->bind(); vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(vertices.data(), vertices.length() * sizeof(float)); + vbo->allocate(interleaved.data(), (int)(interleaved.size() * sizeof(float))); + gl->glEnableVertexAttribArray((int)VertexUsage::Position); gl->glVertexAttribPointer((int)VertexUsage::Position, 3, GL_FLOAT, GL_FALSE, - 3 * sizeof(float), BUFFER_OFFSET(0)); + kStride, BUFFER_OFFSET(0)); - // normal - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); - vbo->create(); - vbo->bind(); - vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(normals.data(), normals.length() * sizeof(float)); gl->glEnableVertexAttribArray((int)VertexUsage::Normal); gl->glVertexAttribPointer((int)VertexUsage::Normal, 3, GL_FLOAT, GL_FALSE, - 3 * sizeof(float), BUFFER_OFFSET(0)); + kStride, BUFFER_OFFSET(3 * sizeof(float))); - // uv - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); - vbo->create(); - vbo->bind(); - vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(uvs.data(), uvs.length() * sizeof(float)); gl->glEnableVertexAttribArray((int)VertexUsage::TexCoord0); - gl->glVertexAttribPointer((int)VertexUsage::TexCoord0, 2, GL_FLOAT, - GL_FALSE, 2 * sizeof(float), BUFFER_OFFSET(0)); + gl->glVertexAttribPointer((int)VertexUsage::TexCoord0, 2, GL_FLOAT, GL_FALSE, + kStride, BUFFER_OFFSET(6 * sizeof(float))); - // tangent - vbo = new QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); - vbo->create(); - vbo->bind(); - vbo->setUsagePattern(QOpenGLBuffer::StaticDraw); - vbo->allocate(tangents.data(), tangents.length() * sizeof(float)); gl->glEnableVertexAttribArray((int)VertexUsage::Tangent); gl->glVertexAttribPointer((int)VertexUsage::Tangent, 4, GL_FLOAT, GL_FALSE, - 4 * sizeof(float), BUFFER_OFFSET(0)); + kStride, BUFFER_OFFSET(8 * sizeof(float))); vao->release(); - // indices auto ibo = new QOpenGLBuffer(QOpenGLBuffer::IndexBuffer); ibo->create(); ibo->bind(); ibo->setUsagePattern(QOpenGLBuffer::StaticDraw); - ibo->allocate(indices.data(), indices.length() * sizeof(unsigned int)); + ibo->allocate(indices.data(), (int)(indices.size() * sizeof(unsigned int))); auto mesh = new Mesh(); - mesh->vao = vao; - mesh->meshType = MeshType::Generated; - mesh->indexBuffer = ibo; - mesh->numElements = indices.count(); + mesh->vao = vao; + mesh->meshType = MeshType::Generated; + mesh->indexBuffer = ibo; + mesh->numElements = (int)indices.size(); mesh->indexByteOffset = 0; - mesh->indexType = GL_UNSIGNED_INT; + mesh->indexType = GL_UNSIGNED_INT; mesh->primitiveMode = GL_TRIANGLES; return mesh; -} \ No newline at end of file +} diff --git a/src/viewer3d/renderer/renderer.cpp b/src/viewer3d/renderer/renderer.cpp index 4ec5d0dc..ff9b282b 100644 --- a/src/viewer3d/renderer/renderer.cpp +++ b/src/viewer3d/renderer/renderer.cpp @@ -4,6 +4,8 @@ #include "../shadercache.h" #include +#include +#include #include #include @@ -28,6 +30,22 @@ class MeshPrivate { tinygltf::Accessor indexAccessor; }; +Mesh::~Mesh() +{ + // For glTF meshes, indexBuffer aliases one of the vbos entries (see + // loadMeshFromRc), so delete it only if it isn't already owned by vbos. + bool indexAliased = false; + for (auto& kv : vbos) { + if (kv.second == indexBuffer) + indexAliased = true; + delete kv.second; + } + if (indexBuffer && !indexAliased) + delete indexBuffer; + delete vao; + // material is not owned by the mesh (shared, owned by Viewer3D) — not freed. +} + void Renderer::init(QOpenGLFunctions* gl) { this->gl = gl; @@ -52,12 +70,31 @@ void Renderer::init(QOpenGLFunctions* gl) iblSampler->gl = gl; } -void Renderer::loadEnvironment(const QString& path) +void Renderer::loadEnvironment(const QString& path, float rotationDegrees) { + this->envRotation = rotationDegrees; iblSampler->init(path); iblSampler->filterAll(); } +void Renderer::setEnvironmentRotation(float degrees) +{ + this->envRotation = degrees; +} + +QMatrix3x3 Renderer::envRotationMatrix() const +{ + const float rad = qDegreesToRadians(envRotation); + const float c = std::cos(rad); + const float s = std::sin(rad); + + // Row-major yaw about the up (Y) axis. The shaders apply it to the lookup + // direction, which turns the environment itself by the same angle: content + // sitting at azimuth a ends up seen at azimuth a + envRotation. + const float values[9] = {c, 0.0f, s, 0.0f, 1.0f, 0.0f, -s, 0.0f, c}; + return QMatrix3x3(values); +} + void Renderer::renderMesh(Mesh* mesh, Material* material) {} void Renderer::updateMaterial(Material* material) @@ -91,6 +128,10 @@ void Renderer::updateMaterial(Material* material) flags << "HAS_ROUGHNESS_MAP 1"; if (material->heightMapId != 0) flags << "HAS_HEIGHT_MAP 1"; + if (material->aoMapId != 0) + flags << "HAS_OCCLUSION_MAP 1"; + if (material->alphaMapId != 0) + flags << "HAS_ALPHA_MAP 1"; // flags << "HAS_NORMAL_MAP 1"; // flags << "HAS_ROUGHNESS_MAP 1"; // flags << "HAS_METALNESS_MAP 1"; @@ -108,7 +149,7 @@ void Renderer::updateMaterial(Material* material) flags << "ALPHAMODE_OPAQUE 0"; flags << "ALPHAMODE_MASK 1"; flags << "ALPHAMODE_BLEND 2"; - flags << "ALPHAMODE ALPHAMODE_OPAQUE"; + flags << "ALPHAMODE ALPHAMODE_BLEND"; // tone mapping (match 3js as much as we can) flags << "TONEMAP_ACES_HILL 1"; @@ -256,6 +297,9 @@ void Renderer::renderGltfMesh(Mesh* mesh, Material* material, const QMatrix4x4& viewMatrix, const QMatrix4x4& projMatrix) { + if (!mesh || !material) + return; + // setup material auto mat = material; if (mat->needsUpdate) { @@ -297,28 +341,45 @@ void Renderer::renderGltfMesh(Mesh* mesh, Material* material, shader->setUniformValue("u_EmissiveUVSet", 0); shader->setUniformValue("u_MetallicRoughnessUVSet", 0); + auto bindLinear = [&](GLuint texId) { + gl->glBindTexture(GL_TEXTURE_2D, texId); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + }; + shader->setUniformValue("u_BaseColorSampler", 0); gl->glActiveTexture(GL_TEXTURE0); - gl->glBindTexture(GL_TEXTURE_2D, mat->albedoMapId); + bindLinear(mat->albedoMapId); shader->setUniformValue("u_NormalSampler", 1); gl->glActiveTexture(GL_TEXTURE1); - gl->glBindTexture(GL_TEXTURE_2D, mat->normalMapId); + bindLinear(mat->normalMapId); shader->setUniformValue("u_MetalnessSampler", 2); gl->glActiveTexture(GL_TEXTURE2); - gl->glBindTexture(GL_TEXTURE_2D, mat->metalnessMapId); + bindLinear(mat->metalnessMapId); shader->setUniformValue("u_RoughnessSampler", 3); gl->glActiveTexture(GL_TEXTURE3); - gl->glBindTexture(GL_TEXTURE_2D, mat->roughnessMapId); + bindLinear(mat->roughnessMapId); shader->setUniformValue("u_HeightSampler", 4); gl->glActiveTexture(GL_TEXTURE4); - gl->glBindTexture(GL_TEXTURE_2D, mat->heightMapId); + bindLinear(mat->heightMapId); shader->setUniformValue("u_HeightScale", material->heightScale); + shader->setUniformValue("u_OcclusionSampler", 5); + gl->glActiveTexture(GL_TEXTURE5); + bindLinear(mat->aoMapId); + shader->setUniformValue("u_OcclusionUVSet", 0); + shader->setUniformValue("u_OcclusionStrength", 1.0f); + + shader->setUniformValue("u_AlphaSampler", 6); + gl->glActiveTexture(GL_TEXTURE6); + bindLinear(mat->alphaMapId); + shader->setUniformValue("u_AlphaUVSet", 0); + // albedo // mainProgram->setUniformValue("u_BaseColorFactor", mat->albedo); // shader->setUniformValue("u_BaseColorSampler", 0); @@ -355,9 +416,7 @@ void Renderer::renderGltfMesh(Mesh* mesh, Material* material, shader->setUniformValue("u_MipCount", iblSampler->mipmapLevels); - QMatrix3x3 envRot; - envRot.setToIdentity(); - shader->setUniformValue("u_EnvRotation", envRot); + shader->setUniformValue("u_EnvRotation", envRotationMatrix()); shader->setUniformValue("u_EnvIntensity", 1.0f); // Setup punctual lights (matches Three.js setupLighting) - conditional @@ -493,6 +552,7 @@ void Renderer::renderSkybox(Mesh* mesh, const QMatrix4x4& viewMatrix, skyboxShader->setUniformValue("u_modelMatrix", modelMatrix); skyboxShader->setUniformValue("u_viewMatrix", viewMatrix); skyboxShader->setUniformValue("u_projectionMatrix", projMatrix); + skyboxShader->setUniformValue("u_envRotation", envRotationMatrix()); // Bind environment cubemap gl->glActiveTexture(GL_TEXTURE0); diff --git a/src/viewer3d/renderer/renderer.h b/src/viewer3d/renderer/renderer.h index 7857aa5f..6ea6430b 100644 --- a/src/viewer3d/renderer/renderer.h +++ b/src/viewer3d/renderer/renderer.h @@ -35,6 +35,14 @@ enum class MeshType { Generated, Gltf }; class MeshPrivate; class Mesh { public: + Mesh() = default; + // Frees the owned GL objects (vao, vbos, indexBuffer). Defined in + // renderer.cpp where those types are complete. + ~Mesh(); + // Owns raw GL pointers; non-copyable to avoid double-free. + Mesh(const Mesh&) = delete; + Mesh& operator=(const Mesh&) = delete; + QOpenGLVertexArrayObject* vao = nullptr; std::map vbos; QList attribs; @@ -64,6 +72,8 @@ struct Material { GLuint metalnessMapId = 0; GLuint roughnessMapId = 0; GLuint heightMapId = 0; + GLuint aoMapId = 0; + GLuint alphaMapId = 0; // QOpenGLTexture* albedoMap = nullptr; // QOpenGLTexture* normalMap = nullptr; @@ -94,8 +104,17 @@ class Renderer { QOpenGLShaderProgram* skyboxShader = nullptr; bool usePunctualLights = true; // Toggle punctual lighting (Three.js style) + // Yaw applied to the environment about the up axis, in degrees. Several + // HDRIs face their darkest quarter at the default camera, so each sky + // carries a rotation that turns its bright side towards the viewer. + float envRotation = 0.0f; + void init(QOpenGLFunctions* gl); - void loadEnvironment(const QString& path); + void loadEnvironment(const QString& path, float rotationDegrees = 0.0f); + void setEnvironmentRotation(float degrees); + // Yaw matrix handed to both the skybox and the IBL lookups so the + // background and the lighting always agree. + QMatrix3x3 envRotationMatrix() const; void renderMesh(Mesh* mesh, Material* material); void updateMaterial(Material* material); diff --git a/src/viewer3d/viewer3d.cpp b/src/viewer3d/viewer3d.cpp index cb108884..b089273b 100644 --- a/src/viewer3d/viewer3d.cpp +++ b/src/viewer3d/viewer3d.cpp @@ -1,4 +1,6 @@ #include "viewer3d.h" +#include "thememanager.h" +#include "tokens.h" #include #include #include @@ -27,6 +29,11 @@ #include "geometry/geometry.h" #include "renderer/renderer.h" +// Environment used when the host app doesn't pick one. The rotation turns the +// HDRI's bright side towards the default camera; see View3DWidget's env list. +static const char* kFallbackEnvPath = ":env/studio_kontrast_03_1k.hdr"; +static const float kFallbackEnvRotation = 160.0f; + QOpenGLShaderProgram* createMainShader(); QOpenGLBuffer* loadMesh(); Mesh* loadMeshFromRc(const QString& path); @@ -85,7 +92,7 @@ void Viewer3D::initializeGL() mat->roughness = 0.5; // Three.js default mat->metalness = 0.0; // Three.js default // gltfMesh = loadMeshFromRc(":assets/cube.gltf"); - gltfMesh = createSphere(this->gl, 2, 64, 64); + gltfMesh = createSphere(this->gl, 2, 1000, 1000); this->material = mat; // Create skydome for rendering environment @@ -109,14 +116,15 @@ void Viewer3D::initializeGL() renderer = new Renderer(); renderer->init(this->gl); if (!defaultEnvPath.isEmpty()) - renderer->loadEnvironment(defaultEnvPath); + renderer->loadEnvironment(defaultEnvPath, defaultEnvRotation); else - renderer->loadEnvironment(":assets/panorama.hdr"); + renderer->loadEnvironment(kFallbackEnvPath, kFallbackEnvRotation); } -void Viewer3D::setDefaultEnvironment(const QString path) +void Viewer3D::setDefaultEnvironment(const QString path, float rotation) { this->defaultEnvPath = path; + this->defaultEnvRotation = rotation; } void Viewer3D::paintGL() @@ -127,26 +135,41 @@ void Viewer3D::paintGL() // also, the supplied width and height are incorrect // gl->glViewport(0, 0, this->width(), this->height()); gl->glClearDepthf(1.0); - gl->glClearColor(0.1, 0.1, 0.1, 1); + // Themed clear color; read each frame so it follows --dev-theme hot-reload. + const QColor clear = ThemeManager::instance().theme().color(Tokens::View3dClear); + gl->glClearColor(clear.redF(), clear.greenF(), clear.blueF(), 1); gl->glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); gl->glEnable(GL_DEPTH_TEST); - // gl->glDisable(GL_CULL_FACE); + gl->glDisable(GL_CULL_FACE); vao->bind(); // Render skydome first (as background) if (skydomeMesh) { - gl->glDepthFunc(GL_LEQUAL); // Change depth function for skybox - gl->glDisable(GL_CULL_FACE); // Render from inside + gl->glDepthFunc(GL_LEQUAL); renderer->renderSkybox(skydomeMesh, viewMatrix, projMatrix); - gl->glEnable(GL_CULL_FACE); - gl->glDepthFunc(GL_LESS); // Reset depth function + gl->glDepthFunc(GL_LESS); } - // render gltf mesh - renderer->renderGltfMesh(gltfMesh, material, camPos, worldMatrix, - viewMatrix, projMatrix); + // render gltf mesh, double-sided: draw the back faces first and the + // front faces second so alpha-blended fragments composite back-to-front + gl->glEnable(GL_BLEND); + gl->glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + gl->glEnable(GL_CULL_FACE); + + if (gltfMesh) { + gl->glCullFace(GL_FRONT); + renderer->renderGltfMesh(gltfMesh, material, camPos, worldMatrix, + viewMatrix, projMatrix); + + gl->glCullFace(GL_BACK); + renderer->renderGltfMesh(gltfMesh, material, camPos, worldMatrix, + viewMatrix, projMatrix); + } + + gl->glDisable(GL_CULL_FACE); + gl->glDisable(GL_BLEND); // test several in a row // int totalSpheres = 6; @@ -466,6 +489,8 @@ void Viewer3D::setAlbedoTexture(GLuint texId) void Viewer3D::clearAlbedoTexture() { + if (!this->material) + return; this->material->albedoMapId = 0; this->material->needsUpdate = true; } @@ -478,6 +503,8 @@ void Viewer3D::setNormalTexture(GLuint texId) void Viewer3D::clearNormalTexture() { + if (!this->material) + return; this->material->normalMapId = 0; this->material->needsUpdate = true; } @@ -490,6 +517,8 @@ void Viewer3D::setMetalnessTexture(GLuint texId) void Viewer3D::clearMetalnessTexture() { + if (!this->material) + return; this->material->metalnessMapId = 0; this->material->needsUpdate = true; } @@ -502,6 +531,8 @@ void Viewer3D::setRoughnessTexture(GLuint texId) void Viewer3D::clearRoughnessTexture() { + if (!this->material) + return; this->material->roughnessMapId = 0; this->material->needsUpdate = true; } @@ -514,6 +545,8 @@ void Viewer3D::setHeightTexture(GLuint texId) void Viewer3D::clearHeightTexture() { + if (!this->material) + return; this->material->heightMapId = 0; this->material->needsUpdate = true; } @@ -524,6 +557,34 @@ void Viewer3D::setHeightScale(float scale) this->material->needsUpdate = true; } +void Viewer3D::setAoTexture(GLuint texId) +{ + this->material->aoMapId = texId; + this->material->needsUpdate = true; +} + +void Viewer3D::clearAoTexture() +{ + if (!this->material) + return; + this->material->aoMapId = 0; + this->material->needsUpdate = true; +} + +void Viewer3D::setAlphaTexture(GLuint texId) +{ + this->material->alphaMapId = texId; + this->material->needsUpdate = true; +} + +void Viewer3D::clearAlphaTexture() +{ + if (!this->material) + return; + this->material->alphaMapId = 0; + this->material->needsUpdate = true; +} + void Viewer3D::clearTextures() { this->clearAlbedoTexture(); @@ -531,6 +592,8 @@ void Viewer3D::clearTextures() this->clearMetalnessTexture(); this->clearRoughnessTexture(); this->clearHeightTexture(); + this->clearAoTexture(); + this->clearAlphaTexture(); } void Viewer3D::resetCamera() @@ -542,10 +605,18 @@ void Viewer3D::resetCamera() this->repaint(); } -void Viewer3D::loadEnvironment(const QString path) +void Viewer3D::loadEnvironment(const QString path, float rotation) { if (renderer) { - renderer->loadEnvironment(path); + renderer->loadEnvironment(path, rotation); + this->repaint(); + } +} + +void Viewer3D::setEnvironmentRotation(float rotation) +{ + if (renderer) { + renderer->setEnvironmentRotation(rotation); this->repaint(); } } @@ -555,43 +626,45 @@ void Viewer3D::setModel(const QString& modelType) // Bind OpenGL context makeCurrent(); - // Clean up old mesh - if (gltfMesh) { - delete gltfMesh; - gltfMesh = nullptr; - } - - // Create new mesh based on type + // Build the new mesh into a local first; only swap in (and free the old + // one) once creation succeeds, so a failed allocation can't leave gltfMesh + // null or delete the current mesh prematurely. + Mesh* newMesh = nullptr; if (modelType == "sphere") { - gltfMesh = createSphere(this->gl, 2, 64, 64); + newMesh = createSphere(this->gl, 2, 1000, 1000); } else if (modelType == "plane_xy") { // Create a subdivided plane in XY orientation - gltfMesh = createPlane(this->gl, 4, 4, 32, 32, PlaneOrientation::XY); + newMesh = createPlane(this->gl, 4, 4, 1000, 1000, PlaneOrientation::XY); } else if (modelType == "plane_yz") { // Create a subdivided plane in YZ orientation - gltfMesh = createPlane(this->gl, 4, 4, 32, 32, PlaneOrientation::YZ); + newMesh = createPlane(this->gl, 4, 4, 1000, 1000, PlaneOrientation::YZ); } else if (modelType == "plane_xz") { // Create a subdivided plane in XZ orientation - gltfMesh = createPlane(this->gl, 4, 4, 32, 32, PlaneOrientation::XZ); + newMesh = createPlane(this->gl, 4, 4, 1000, 1000, PlaneOrientation::XZ); } else if (modelType == "cylinder") { // Create a cylinder with height subdivisions for displacement mapping - gltfMesh = createCylinder(this->gl, 1, 1, 2, 32, 32, false); + newMesh = createCylinder(this->gl, 1, 1, 2, 1000, 1000, 0.1f, 16); } else if (modelType == "cube") { // Create a subdivided cube - gltfMesh = createCube(this->gl, 2, 2, 2, 32, 32, 32); + newMesh = createCube(this->gl, 2, 2, 2, 1000, 1000, 1000); } else if (modelType == "cubesphere") { // CubeSphere - a sphere with low segments for a more cubic look - gltfMesh = createSphere(this->gl, 2, 8, 8); + newMesh = createSphere(this->gl, 2, 8, 8); } else { // Default to sphere - gltfMesh = createSphere(this->gl, 2, 64, 64); + newMesh = createSphere(this->gl, 2, 1000, 1000); + } + + if (newMesh) { + delete gltfMesh; + gltfMesh = newMesh; } // Release OpenGL context diff --git a/src/viewer3d/viewer3d.h b/src/viewer3d/viewer3d.h index e2013e04..474e2ccb 100644 --- a/src/viewer3d/viewer3d.h +++ b/src/viewer3d/viewer3d.h @@ -33,11 +33,16 @@ class Viewer3D : public QOpenGLWidget { QOpenGLBuffer* mesh = nullptr; QOpenGLVertexArrayObject* vao = nullptr; - Renderer* renderer; - Material* material; - Mesh* gltfMesh; - Mesh* skydomeMesh; + // Initialized to nullptr: these are only assigned in initializeGL() (first + // paint), but setProject()/clearTextures() can run earlier (from the + // MainWindow constructor). Without this, the null-guards in clear*Texture() + // dereference uninitialized garbage and crash. See viewer3d.cpp clear*. + Renderer* renderer = nullptr; + Material* material = nullptr; + Mesh* gltfMesh = nullptr; + Mesh* skydomeMesh = nullptr; QString defaultEnvPath; + float defaultEnvRotation = 0.0f; QOpenGLFunctions* gl = nullptr; @@ -106,18 +111,20 @@ class Viewer3D : public QOpenGLWidget { void setHeightTexture(GLuint texId); void clearHeightTexture(); void setHeightScale(float scale); + void setAoTexture(GLuint texId); + void clearAoTexture(); + void setAlphaTexture(GLuint texId); + void clearAlphaTexture(); void resetMaterial(); - // void setAlphaTexture(GLuint texId); - // void setAoTexture(GLuint texId); - // void setEmissiveTexture(GLuint texId); - // void setHeightTexture(GLuint texId); - void clearTextures(); void resetCamera(); - void loadEnvironment(const QString path); + // rotation is a yaw in degrees about the up axis, used to turn the lit + // side of the HDRI towards the camera. + void loadEnvironment(const QString path, float rotation = 0.0f); + void setEnvironmentRotation(float rotation); void setModel(const QString& modelType); // sets env to use on load - void setDefaultEnvironment(const QString path); + void setDefaultEnvironment(const QString path, float rotation = 0.0f); }; \ No newline at end of file diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 00000000..83a1ff3f --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,63 @@ +cmake_minimum_required(VERSION 3.10) + +# QtTest rather than gtest or catch2: Qt is already a hard dependency, so this +# adds no third-party code to vendor, update, or explain. + +set(CMAKE_AUTOMOC ON) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Core) +find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Test Gui Network) + +# Each test file becomes its own binary, so one crashing suite can't take the +# others' results with it. +function(texturelab_add_test name) + add_executable(${name} ${name}.cpp) + target_link_libraries(${name} PRIVATE + catalog + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Test + ) + add_test(NAME ${name} COMMAND ${name}) + set_tests_properties(${name} PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +endfunction() + +texturelab_add_test(tst_database) +texturelab_add_test(tst_catalogindex) +texturelab_add_test(tst_thumbnailcache) +# QImage/QPixmap round-trip needs the GUI module. +target_link_libraries(tst_thumbnailcache PRIVATE Qt${QT_VERSION_MAJOR}::Gui) + +# Update version ordering: pure QtCore, so it compiles straight into a test. +texturelab_add_test(tst_versioncompare) +target_sources(tst_versioncompare PRIVATE + ${CMAKE_SOURCE_DIR}/src/texturelab/update/versioncompare.cpp) +target_include_directories(tst_versioncompare PRIVATE + ${CMAKE_SOURCE_DIR}/src/texturelab/update) + +# Which server gets asked, and on which channel. Needs Network (the checker +# owns a QNetworkAccessManager) but makes no request. +texturelab_add_test(tst_updateendpoint) +target_sources(tst_updateendpoint PRIVATE + ${CMAKE_SOURCE_DIR}/src/texturelab/update/updatechecker.cpp + ${CMAKE_SOURCE_DIR}/src/texturelab/update/versioncompare.cpp) +target_include_directories(tst_updateendpoint PRIVATE + ${CMAKE_SOURCE_DIR}/src/texturelab/update + ${CMAKE_SOURCE_DIR}/src/texturelab) +target_link_libraries(tst_updateendpoint PRIVATE Qt${QT_VERSION_MAJOR}::Network) +# The launcher's model is deliberately free of app dependencies — Qt plus the +# catalog and nothing else — so its .cpp can be compiled straight into a test +# without dragging in the node graph or an OpenGL context. +texturelab_add_test(tst_texturelistmodel) +target_sources(tst_texturelistmodel PRIVATE + ${CMAKE_SOURCE_DIR}/src/texturelab/launcher/texturelistmodel.cpp) +target_include_directories(tst_texturelistmodel PRIVATE + ${CMAKE_SOURCE_DIR}/src/texturelab/launcher) +target_link_libraries(tst_texturelistmodel PRIVATE Qt${QT_VERSION_MAJOR}::Gui) + +# Loose types in old project files: numbers as strings, ints as floats. +# Header-only, so the test just includes it. +texturelab_add_test(tst_jsonutils) +target_include_directories(tst_jsonutils PRIVATE + ${CMAKE_SOURCE_DIR}/src/texturelab) diff --git a/tests/tst_catalogindex.cpp b/tests/tst_catalogindex.cpp new file mode 100644 index 00000000..d033bdce --- /dev/null +++ b/tests/tst_catalogindex.cpp @@ -0,0 +1,501 @@ +#include "catalogindex.h" + +#include +#include +#include + +using namespace catalog; + +namespace { + +constexpr qint64 kT0 = 1'700'000'000'000LL; // arbitrary fixed "now", in ms + +TextureRecord makeRecord(const QString& path) +{ + TextureRecord rec; + rec.path = path; + rec.name = QFileInfo(path).completeBaseName(); + rec.fileSize = 4096; + rec.fileMtime = kT0; + rec.width = 2048; + rec.height = 2048; + rec.nodeCount = 12; + rec.libVersion = QStringLiteral("v3"); + rec.channels = ChannelAlbedo | ChannelNormal | ChannelRoughness; + return rec; +} + +} // namespace + +class TestCatalogIndex : public QObject { + Q_OBJECT + +private slots: + void init(); + void cleanup(); + + void createsSchemaOnFirstOpen(); + void reopenPreservesRows(); + void refusesToWriteWhenSchemaIsNewer(); + + void recordOpenedInsertsAndStamps(); + void recordOpenedTwiceDoesNotDuplicate(); + void createdAtSurvivesLaterWrites(); + void recordSavedStampsSavedNotOpened(); + void recordingClearsMissingFlag(); + void refusesRecordWithoutPath(); + + void movedFileBecomesASeparateRow(); + + void relocateRepointsAMissingRow(); + void relocateMergesWhenTargetIsAlreadyIndexed(); + void relocateRejectsUnknownRows(); + + void markMissingStampsOnlyOnce(); + void markPresentUpdatesFileStateAndClearsMissing(); + + void listFiltersRecentsExcludingMissing(); + void listFiltersStarredIncludingMissing(); + void listSortsAndPages(); + void searchMatchesNameAndTagsCaseInsensitively(); + void searchTreatsWildcardsLiterally(); + + void tagsRoundTripAndCascadeOnDelete(); + void removeAllMissingOnlyRemovesMissing(); + +private: + QString indexPath() const { return dir->filePath(QStringLiteral("index.db")); } + + QScopedPointer dir; + QScopedPointer index; +}; + +void TestCatalogIndex::init() +{ + dir.reset(new QTemporaryDir); + QVERIFY(dir->isValid()); + index.reset(new CatalogIndex); + QVERIFY(index->open(indexPath())); +} + +void TestCatalogIndex::cleanup() +{ + index.reset(); + dir.reset(); +} + +void TestCatalogIndex::createsSchemaOnFirstOpen() +{ + QVERIFY(index->isOpen()); + QVERIFY(!index->isReadOnly()); + QCOMPARE(index->schemaVersion(), CatalogIndex::SchemaVersion); + QCOMPARE(index->count(Query()), 0); +} + +void TestCatalogIndex::reopenPreservesRows() +{ + TextureRecord rec = makeRecord(QStringLiteral("/tex/brick.texture")); + QVERIFY(index->recordOpened(rec, kT0)); + + index->close(); + QVERIFY(index->open(indexPath())); + + QCOMPARE(index->count(Query()), 1); + const TextureRecord loaded = index->byPath(QStringLiteral("/tex/brick.texture")); + QVERIFY(loaded.isValid()); + QCOMPARE(loaded.name, QStringLiteral("brick")); + QCOMPARE(loaded.channels, int(ChannelAlbedo | ChannelNormal | ChannelRoughness)); + QCOMPARE(loaded.libVersion, QStringLiteral("v3")); +} + +void TestCatalogIndex::refusesToWriteWhenSchemaIsNewer() +{ + // index.db cannot be regenerated by rescanning, so a file from a newer + // build must be left strictly alone rather than migrated on a guess. + TextureRecord rec = makeRecord(QStringLiteral("/tex/a.texture")); + QVERIFY(index->recordOpened(rec, kT0)); + index->close(); + + { + Database raw; + QVERIFY(raw.open(indexPath())); + QVERIFY(raw.exec(QStringLiteral("UPDATE meta SET v = '999' WHERE k = 'schema_version'"))); + } + + QTest::ignoreMessage(QtWarningMsg, + QRegularExpression(QStringLiteral("newer than this build"))); + QVERIFY(index->open(indexPath())); + QVERIFY(index->isReadOnly()); + + // Reads still work — the launcher should show what it can. + QCOMPARE(index->count(Query()), 1); + + TextureRecord blocked = makeRecord(QStringLiteral("/tex/b.texture")); + QVERIFY(!index->recordOpened(blocked, kT0)); + QVERIFY(!index->setStarred(1, true)); + QVERIFY(!index->remove(1)); + QCOMPARE(index->count(Query()), 1); +} + +void TestCatalogIndex::recordOpenedInsertsAndStamps() +{ + TextureRecord rec = makeRecord(QStringLiteral("/tex/brick.texture")); + QVERIFY(index->recordOpened(rec, kT0)); + + QVERIFY(rec.isValid()); + QCOMPARE(rec.lastOpened, kT0); + QCOMPARE(rec.lastSaved, 0LL); + QCOMPARE(rec.createdAt, kT0); + QVERIFY(!rec.isMissing()); + QCOMPARE(index->count(Query()), 1); +} + +void TestCatalogIndex::recordOpenedTwiceDoesNotDuplicate() +{ + TextureRecord first = makeRecord(QStringLiteral("/tex/brick.texture")); + QVERIFY(index->recordOpened(first, kT0)); + + TextureRecord second = makeRecord(QStringLiteral("/tex/brick.texture")); + QVERIFY(index->recordOpened(second, kT0 + 5000)); + + QCOMPARE(index->count(Query()), 1); + QCOMPARE(second.id, first.id); + QCOMPARE(second.lastOpened, kT0 + 5000); +} + +void TestCatalogIndex::createdAtSurvivesLaterWrites() +{ + TextureRecord rec = makeRecord(QStringLiteral("/tex/brick.texture")); + QVERIFY(index->recordOpened(rec, kT0)); + + TextureRecord again = makeRecord(QStringLiteral("/tex/brick.texture")); + QVERIFY(index->recordSaved(again, kT0 + 60'000)); + + QCOMPARE(again.createdAt, kT0); + QCOMPARE(again.lastOpened, kT0); // preserved, not clobbered + QCOMPARE(again.lastSaved, kT0 + 60'000); +} + +void TestCatalogIndex::recordSavedStampsSavedNotOpened() +{ + TextureRecord rec = makeRecord(QStringLiteral("/tex/new.texture")); + QVERIFY(index->recordSaved(rec, kT0)); + + QCOMPARE(rec.lastSaved, kT0); + QCOMPARE(rec.lastOpened, 0LL); + + // A never-opened row must not appear under Recents. + Query recents; + recents.filter = Filter::Recents; + QCOMPARE(index->count(recents), 0); +} + +void TestCatalogIndex::recordingClearsMissingFlag() +{ + TextureRecord rec = makeRecord(QStringLiteral("/tex/brick.texture")); + QVERIFY(index->recordOpened(rec, kT0)); + QVERIFY(index->markMissing(rec.id, kT0 + 1000)); + QVERIFY(index->byId(rec.id).isMissing()); + + TextureRecord again = makeRecord(QStringLiteral("/tex/brick.texture")); + QVERIFY(index->recordOpened(again, kT0 + 2000)); + QVERIFY(!index->byId(rec.id).isMissing()); +} + +void TestCatalogIndex::refusesRecordWithoutPath() +{ + TextureRecord rec; + QVERIFY(!index->recordOpened(rec, kT0)); + QCOMPARE(index->count(Query()), 0); +} + +void TestCatalogIndex::movedFileBecomesASeparateRow() +{ + // Paths are identity. Recognizing that a file moved would need a content + // hash or an mtime heuristic, and neither was worth its keep — so the old + // row simply stays behind, flagged missing, and the user removes it. The + // cost is losing stars and recency on a moved file; nothing breaks. + TextureRecord original = makeRecord(QStringLiteral("/old/brick.texture")); + QVERIFY(index->recordOpened(original, kT0)); + QVERIFY(index->setStarred(original.id, true)); + QVERIFY(index->addTag(original.id, QStringLiteral("stone"))); + QVERIFY(index->markMissing(original.id, kT0 + 1000)); + + TextureRecord moved = makeRecord(QStringLiteral("/new/brick.texture")); + QVERIFY(index->recordOpened(moved, kT0 + 2000)); + + QCOMPARE(index->count(Query()), 2); + QVERIFY(moved.id != original.id); + + // The old row is untouched and still dimmed, holding its own stars. + const TextureRecord stale = index->byId(original.id); + QVERIFY(stale.isValid()); + QVERIFY(stale.isMissing()); + QVERIFY(stale.starred); + + // The new one starts clean. + QVERIFY(!moved.starred); + QVERIFY(index->tags(moved.id).isEmpty()); + + // And "Remove from Launcher" is the way out. + QVERIFY(index->remove(original.id)); + QCOMPARE(index->count(Query()), 1); +} + +void TestCatalogIndex::relocateRepointsAMissingRow() +{ + // The user-driven counterpart to not detecting moves: the launcher can't + // guess where a file went, but Locate… lets the user say, and the row keeps + // everything it had. + TextureRecord rec = makeRecord(QStringLiteral("/old/brick.texture")); + QVERIFY(index->recordOpened(rec, kT0)); + QVERIFY(index->setStarred(rec.id, true)); + QVERIFY(index->addTag(rec.id, QStringLiteral("stone"))); + QVERIFY(index->markMissing(rec.id, kT0 + 1000)); + + const qint64 survivor = + index->relocate(rec.id, QStringLiteral("/new/brick.texture"), 8192, kT0 + 5000); + QCOMPARE(survivor, rec.id); + + const TextureRecord moved = index->byId(rec.id); + QCOMPARE(moved.path, QStringLiteral("/new/brick.texture")); + QCOMPARE(moved.name, QStringLiteral("brick")); + QCOMPARE(moved.fileSize, 8192LL); + QCOMPARE(moved.fileMtime, kT0 + 5000); + QVERIFY(!moved.isMissing()); + QVERIFY(moved.starred); + QCOMPARE(index->tags(moved.id), QStringList({QStringLiteral("stone")})); + QCOMPARE(index->count(Query()), 1); +} + +void TestCatalogIndex::relocateMergesWhenTargetIsAlreadyIndexed() +{ + // path is UNIQUE, so relocating onto a path that's already indexed has to + // merge rather than fail — otherwise Locate… dead-ends exactly when the + // user already opened the file at its new home. + TextureRecord stale = makeRecord(QStringLiteral("/old/brick.texture")); + QVERIFY(index->recordOpened(stale, kT0)); + QVERIFY(index->setStarred(stale.id, true)); + QVERIFY(index->addTag(stale.id, QStringLiteral("stone"))); + QVERIFY(index->markMissing(stale.id, kT0 + 1000)); + + TextureRecord current = makeRecord(QStringLiteral("/new/brick.texture")); + QVERIFY(index->recordOpened(current, kT0 + 2000)); + QVERIFY(!current.starred); + + const qint64 survivor = + index->relocate(stale.id, QStringLiteral("/new/brick.texture"), 4096, kT0 + 2000); + + QCOMPARE(survivor, current.id); + QVERIFY(!index->byId(stale.id).isValid()); + QCOMPARE(index->count(Query()), 1); + + const TextureRecord merged = index->byId(current.id); + QVERIFY(merged.starred); // carried across + QCOMPARE(merged.createdAt, kT0); // earliest of the two + QCOMPARE(index->tags(merged.id), QStringList({QStringLiteral("stone")})); +} + +void TestCatalogIndex::relocateRejectsUnknownRows() +{ + QCOMPARE(index->relocate(999, QStringLiteral("/x.texture"), 1, 1), -1LL); + QCOMPARE(index->relocate(1, QString(), 1, 1), -1LL); +} + +void TestCatalogIndex::markMissingStampsOnlyOnce() +{ + TextureRecord rec = makeRecord(QStringLiteral("/tex/gone.texture")); + QVERIFY(index->recordOpened(rec, kT0)); + + QVERIFY(index->markMissing(rec.id, kT0 + 1000)); + QVERIFY(index->markMissing(rec.id, kT0 + 9999)); + + // The card wants to say how long it's been gone, so the first sighting + // wins rather than being reset on every launcher open. + QCOMPARE(index->byId(rec.id).missingSince, kT0 + 1000); +} + +void TestCatalogIndex::markPresentUpdatesFileStateAndClearsMissing() +{ + // What reconciliation does when a file turns out to still be there: record + // the current size/mtime and un-dim the card. Comparing those two values + // against the stored row is the system's only change detection — a caller + // that sees them differ drops the cached thumbnail before calling this. + TextureRecord rec = makeRecord(QStringLiteral("/tex/edited.texture")); + QVERIFY(index->recordOpened(rec, kT0)); + QVERIFY(index->markMissing(rec.id, kT0 + 1000)); + + QVERIFY(index->markPresent(rec.id, 8192, kT0 + 5000)); + + const TextureRecord updated = index->byId(rec.id); + QVERIFY(!updated.isMissing()); + QCOMPARE(updated.fileSize, 8192LL); + QCOMPARE(updated.fileMtime, kT0 + 5000); +} + +void TestCatalogIndex::listFiltersRecentsExcludingMissing() +{ + TextureRecord opened = makeRecord(QStringLiteral("/tex/opened.texture")); + QVERIFY(index->recordOpened(opened, kT0)); + + TextureRecord savedOnly = makeRecord(QStringLiteral("/tex/saved.texture")); + QVERIFY(index->recordSaved(savedOnly, kT0)); + + TextureRecord missing = makeRecord(QStringLiteral("/tex/missing.texture")); + QVERIFY(index->recordOpened(missing, kT0)); + QVERIFY(index->markMissing(missing.id, kT0 + 1000)); + + Query recents; + recents.filter = Filter::Recents; + const QVector rows = index->list(recents); + + QCOMPARE(rows.size(), 1); + QCOMPARE(rows.first().path, QStringLiteral("/tex/opened.texture")); + + // All still shows everything, missing included — those cards render dimmed. + QCOMPARE(index->count(Query()), 3); +} + +void TestCatalogIndex::listFiltersStarredIncludingMissing() +{ + TextureRecord starred = makeRecord(QStringLiteral("/tex/star.texture")); + QVERIFY(index->recordOpened(starred, kT0)); + QVERIFY(index->setStarred(starred.id, true)); + QVERIFY(index->markMissing(starred.id, kT0 + 1000)); + + TextureRecord plain = makeRecord(QStringLiteral("/tex/plain.texture")); + QVERIFY(index->recordOpened(plain, kT0)); + + Query query; + query.filter = Filter::Starred; + const QVector rows = index->list(query); + + // An unmounted drive must not hide the things the user explicitly marked. + QCOMPARE(rows.size(), 1); + QCOMPARE(rows.first().path, QStringLiteral("/tex/star.texture")); + QVERIFY(rows.first().isMissing()); +} + +void TestCatalogIndex::listSortsAndPages() +{ + const QStringList names = {QStringLiteral("charlie"), QStringLiteral("alpha"), + QStringLiteral("bravo"), QStringLiteral("delta")}; + for (int i = 0; i < names.size(); ++i) { + TextureRecord rec = makeRecord(QStringLiteral("/tex/%1.texture").arg(names[i])); + rec.fileMtime = kT0 + i * 1000; + rec.fileSize = 1000 * (i + 1); + QVERIFY(index->recordOpened(rec, kT0 + i * 1000)); + } + + Query byName; + byName.sort = SortKey::Name; + byName.ascending = true; + QVector rows = index->list(byName); + QCOMPARE(rows.size(), 4); + QCOMPARE(rows[0].name, QStringLiteral("alpha")); + QCOMPARE(rows[3].name, QStringLiteral("delta")); + + Query newestFirst; + newestFirst.sort = SortKey::Modified; + newestFirst.ascending = false; + rows = index->list(newestFirst); + QCOMPARE(rows.first().name, QStringLiteral("delta")); + + Query biggestFirst; + biggestFirst.sort = SortKey::Size; + biggestFirst.ascending = false; + rows = index->list(biggestFirst); + QCOMPARE(rows.first().fileSize, 4000LL); + + // Paging: the grid asks for a window, not the whole table. + Query page; + page.sort = SortKey::Name; + page.ascending = true; + page.limit = 2; + page.offset = 1; + rows = index->list(page); + QCOMPARE(rows.size(), 2); + QCOMPARE(rows[0].name, QStringLiteral("bravo")); + QCOMPARE(rows[1].name, QStringLiteral("charlie")); + + // count() ignores paging — it's the total the scrollbar needs. + QCOMPARE(index->count(page), 4); +} + +void TestCatalogIndex::searchMatchesNameAndTagsCaseInsensitively() +{ + TextureRecord brick = makeRecord(QStringLiteral("/tex/BrickWall.texture")); + QVERIFY(index->recordOpened(brick, kT0)); + + TextureRecord metal = makeRecord(QStringLiteral("/tex/rust.texture")); + QVERIFY(index->recordOpened(metal, kT0)); + QVERIFY(index->addTag(metal.id, QStringLiteral("Brickish"))); + + TextureRecord other = makeRecord(QStringLiteral("/tex/cloth.texture")); + QVERIFY(index->recordOpened(other, kT0)); + + Query query; + query.search = QStringLiteral("brick"); + const QVector rows = index->list(query); + + QCOMPARE(rows.size(), 2); + QCOMPARE(index->count(query), 2); +} + +void TestCatalogIndex::searchTreatsWildcardsLiterally() +{ + TextureRecord percent = makeRecord(QStringLiteral("/tex/50%25 grey.texture")); + percent.name = QStringLiteral("50% grey"); + QVERIFY(index->recordOpened(percent, kT0)); + + TextureRecord other = makeRecord(QStringLiteral("/tex/brick.texture")); + QVERIFY(index->recordOpened(other, kT0)); + + // Unescaped, "%" would match every row. + Query query; + query.search = QStringLiteral("%"); + QCOMPARE(index->count(query), 1); + + // Same for the single-character wildcard. + query.search = QStringLiteral("_"); + QCOMPARE(index->count(query), 0); +} + +void TestCatalogIndex::tagsRoundTripAndCascadeOnDelete() +{ + TextureRecord rec = makeRecord(QStringLiteral("/tex/tagged.texture")); + QVERIFY(index->recordOpened(rec, kT0)); + + QVERIFY(index->addTag(rec.id, QStringLiteral("stone"))); + QVERIFY(index->addTag(rec.id, QStringLiteral("outdoor"))); + QVERIFY(index->addTag(rec.id, QStringLiteral("stone"))); // idempotent + + QCOMPARE(index->tags(rec.id), + QStringList({QStringLiteral("outdoor"), QStringLiteral("stone")})); + + QVERIFY(index->removeTag(rec.id, QStringLiteral("outdoor"))); + QCOMPARE(index->tags(rec.id), QStringList({QStringLiteral("stone")})); + + // The tag table is WITHOUT ROWID with an ON DELETE CASCADE foreign key; + // this proves both are actually in force. + QVERIFY(index->remove(rec.id)); + QCOMPARE(index->tags(rec.id), QStringList()); + QCOMPARE(index->database().scalar(QStringLiteral("SELECT count(*) FROM tag")), 0LL); +} + +void TestCatalogIndex::removeAllMissingOnlyRemovesMissing() +{ + TextureRecord present = makeRecord(QStringLiteral("/tex/here.texture")); + QVERIFY(index->recordOpened(present, kT0)); + + TextureRecord gone = makeRecord(QStringLiteral("/tex/gone.texture")); + QVERIFY(index->recordOpened(gone, kT0)); + QVERIFY(index->markMissing(gone.id, kT0 + 1000)); + + QCOMPARE(index->removeAllMissing(), 1); + QCOMPARE(index->count(Query()), 1); + QVERIFY(index->byPath(QStringLiteral("/tex/here.texture")).isValid()); +} + +QTEST_GUILESS_MAIN(TestCatalogIndex) +#include "tst_catalogindex.moc" diff --git a/tests/tst_database.cpp b/tests/tst_database.cpp new file mode 100644 index 00000000..30ab3584 --- /dev/null +++ b/tests/tst_database.cpp @@ -0,0 +1,293 @@ +#include "database.h" + +#include +#include +#include + +using namespace catalog; + +class TestDatabase : public QObject { + Q_OBJECT + +private slots: + void init(); + + void opensFileAndReportsOpen(); + void appliesPageSizeBeforeAnyDdl(); + void appliesIncrementalAutoVacuum(); + void enablesWalMode(); + void enablesForeignKeys(); + void memoryDatabaseSkipsFilePragmas(); + + void transactionCommitPersists(); + void transactionRollsBackWhenScopeExits(); + void transactionRollsBackOnExplicitCall(); + void nestedTransactionIsInertAndDoesNotCommitOuter(); + + void execBatchIsAtomic(); + void scalarReturnsFallbackOnFailure(); + void readOnlyConnectionRejectsWrites(); + + void separateConnectionsCanReadConcurrently(); + +private: + QString dbFile(const QString& name) const { return dir.filePath(name); } + + QTemporaryDir dir; +}; + +void TestDatabase::init() +{ + QVERIFY(dir.isValid()); +} + +void TestDatabase::opensFileAndReportsOpen() +{ + Database db; + QVERIFY(db.open(dbFile(QStringLiteral("open.db")))); + QVERIFY(db.isOpen()); + + db.close(); + QVERIFY(!db.isOpen()); +} + +void TestDatabase::appliesPageSizeBeforeAnyDdl() +{ + // The ordering this asserts is the whole reason Database applies PRAGMAs + // itself: page_size only takes on an empty file. If a future refactor + // creates a table first, this drops back to the 4096 default and the + // setting is silently lost. + const QString path = dbFile(QStringLiteral("pagesize.db")); + + Database::Options options; + options.pageSize = 8192; + + Database db; + QVERIFY(db.open(path, options)); + QVERIFY(db.exec(QStringLiteral("CREATE TABLE t (a INTEGER)"))); + + QCOMPARE(db.scalar(QStringLiteral("PRAGMA page_size")), 8192LL); + + // And it survives a reopen, i.e. it was written into the file header + // rather than just held in the connection. + db.close(); + Database reopened; + QVERIFY(reopened.open(path)); + QCOMPARE(reopened.scalar(QStringLiteral("PRAGMA page_size")), 8192LL); +} + +void TestDatabase::appliesIncrementalAutoVacuum() +{ + const QString path = dbFile(QStringLiteral("autovacuum.db")); + + Database::Options options; + options.incrementalAutoVacuum = true; + + Database db; + QVERIFY(db.open(path, options)); + QVERIFY(db.exec(QStringLiteral("CREATE TABLE t (a INTEGER)"))); + + // 0 = NONE, 1 = FULL, 2 = INCREMENTAL. + QCOMPARE(db.scalar(QStringLiteral("PRAGMA auto_vacuum")), 2LL); + + db.close(); + Database reopened; + QVERIFY(reopened.open(path)); + QCOMPARE(reopened.scalar(QStringLiteral("PRAGMA auto_vacuum")), 2LL); +} + +void TestDatabase::enablesWalMode() +{ + Database db; + QVERIFY(db.open(dbFile(QStringLiteral("wal.db")))); + + QSqlQuery query = db.prepare(QStringLiteral("PRAGMA journal_mode")); + QVERIFY(query.exec()); + QVERIFY(query.next()); + QCOMPARE(query.value(0).toString().toLower(), QStringLiteral("wal")); +} + +void TestDatabase::enablesForeignKeys() +{ + Database db; + QVERIFY(db.open(dbFile(QStringLiteral("fk.db")))); + QCOMPARE(db.scalar(QStringLiteral("PRAGMA foreign_keys")), 1LL); + + QVERIFY(db.exec(QStringLiteral("CREATE TABLE parent (id INTEGER PRIMARY KEY)"))); + QVERIFY(db.exec(QStringLiteral( + "CREATE TABLE child (id INTEGER PRIMARY KEY, " + "parent_id INTEGER REFERENCES parent(id) ON DELETE CASCADE)"))); + QVERIFY(db.exec(QStringLiteral("INSERT INTO parent (id) VALUES (1)"))); + QVERIFY(db.exec(QStringLiteral("INSERT INTO child (id, parent_id) VALUES (1, 1)"))); + + // The cascade is what the tag table depends on for cleanup. + QVERIFY(db.exec(QStringLiteral("DELETE FROM parent WHERE id = 1"))); + QCOMPARE(db.scalar(QStringLiteral("SELECT count(*) FROM child")), 0LL); +} + +void TestDatabase::memoryDatabaseSkipsFilePragmas() +{ + // WAL is meaningless in memory and setting it fails; open() must not treat + // that as an error, because every test below uses :memory:. + Database db; + QVERIFY(db.open(QStringLiteral(":memory:"))); + QVERIFY(db.isOpen()); + QVERIFY(db.exec(QStringLiteral("CREATE TABLE t (a INTEGER)"))); +} + +void TestDatabase::transactionCommitPersists() +{ + Database db; + QVERIFY(db.open(QStringLiteral(":memory:"))); + QVERIFY(db.exec(QStringLiteral("CREATE TABLE t (a INTEGER)"))); + + { + Transaction tx(db); + QVERIFY(tx.isActive()); + QVERIFY(db.exec(QStringLiteral("INSERT INTO t (a) VALUES (1)"))); + QVERIFY(tx.commit()); + } + + QCOMPARE(db.scalar(QStringLiteral("SELECT count(*) FROM t")), 1LL); +} + +void TestDatabase::transactionRollsBackWhenScopeExits() +{ + // The property that matters: an early return anywhere inside a write batch + // leaves nothing behind. Nobody has to remember to roll back. + Database db; + QVERIFY(db.open(QStringLiteral(":memory:"))); + QVERIFY(db.exec(QStringLiteral("CREATE TABLE t (a INTEGER)"))); + + { + Transaction tx(db); + QVERIFY(tx.isActive()); + QVERIFY(db.exec(QStringLiteral("INSERT INTO t (a) VALUES (1)"))); + // No commit — destructor rolls back. + } + + QCOMPARE(db.scalar(QStringLiteral("SELECT count(*) FROM t")), 0LL); + QVERIFY(!db.inTransaction()); +} + +void TestDatabase::transactionRollsBackOnExplicitCall() +{ + Database db; + QVERIFY(db.open(QStringLiteral(":memory:"))); + QVERIFY(db.exec(QStringLiteral("CREATE TABLE t (a INTEGER)"))); + + Transaction tx(db); + QVERIFY(db.exec(QStringLiteral("INSERT INTO t (a) VALUES (1)"))); + tx.rollback(); + + QVERIFY(!tx.isActive()); + QCOMPARE(db.scalar(QStringLiteral("SELECT count(*) FROM t")), 0LL); +} + +void TestDatabase::nestedTransactionIsInertAndDoesNotCommitOuter() +{ + Database db; + QVERIFY(db.open(QStringLiteral(":memory:"))); + QVERIFY(db.exec(QStringLiteral("CREATE TABLE t (a INTEGER)"))); + + { + Transaction outer(db); + QVERIFY(outer.isActive()); + QVERIFY(db.exec(QStringLiteral("INSERT INTO t (a) VALUES (1)"))); + + { + QTest::ignoreMessage(QtWarningMsg, + "catalog: nested transaction requested; inner scope is inert"); + Transaction inner(db); + QVERIFY(!inner.isActive()); + QVERIFY(!inner.commit()); + } + + // The inner scope ending must not have committed or rolled back the + // outer one — the write is still pending and still reversible. + QVERIFY(db.inTransaction()); + QCOMPARE(db.scalar(QStringLiteral("SELECT count(*) FROM t")), 1LL); + } + + QCOMPARE(db.scalar(QStringLiteral("SELECT count(*) FROM t")), 0LL); +} + +void TestDatabase::execBatchIsAtomic() +{ + Database db; + QVERIFY(db.open(QStringLiteral(":memory:"))); + + QStringList statements; + statements << QStringLiteral("CREATE TABLE a (x INTEGER)") + << QStringLiteral("CREATE TABLE b (x INTEGER)") + << QStringLiteral("THIS IS NOT SQL"); + + QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("catalog: query failed"))); + QVERIFY(!db.execBatch(statements)); + + // Neither table should exist: the batch rolled back as a unit. + QCOMPARE(db.scalar(QStringLiteral("SELECT count(*) FROM sqlite_master WHERE type='table'")), + 0LL); +} + +void TestDatabase::scalarReturnsFallbackOnFailure() +{ + Database db; + QVERIFY(db.open(QStringLiteral(":memory:"))); + + QCOMPARE(db.scalar(QStringLiteral("SELECT x FROM nonexistent"), -7), -7LL); + QCOMPARE(db.scalar(QStringLiteral("SELECT NULL"), -7), -7LL); + QCOMPARE(db.scalar(QStringLiteral("SELECT 42"), -7), 42LL); +} + +void TestDatabase::readOnlyConnectionRejectsWrites() +{ + const QString path = dbFile(QStringLiteral("readonly.db")); + + { + Database writable; + QVERIFY(writable.open(path)); + QVERIFY(writable.exec(QStringLiteral("CREATE TABLE t (a INTEGER)"))); + QVERIFY(writable.exec(QStringLiteral("INSERT INTO t (a) VALUES (1)"))); + } + + Database::Options options; + options.readOnly = true; + + Database readonly; + QVERIFY(readonly.open(path, options)); + QCOMPARE(readonly.scalar(QStringLiteral("SELECT count(*) FROM t")), 1LL); + + QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("catalog: query failed"))); + QVERIFY(!readonly.exec(QStringLiteral("INSERT INTO t (a) VALUES (2)"))); +} + +void TestDatabase::separateConnectionsCanReadConcurrently() +{ + // WAL's actual promise: a reader on one connection is not blocked by an + // open write transaction on another. The reconciliation pass depends on + // this, since it runs off-thread while the GUI reads. + const QString path = dbFile(QStringLiteral("concurrent.db")); + + Database writer; + QVERIFY(writer.open(path)); + QVERIFY(writer.exec(QStringLiteral("CREATE TABLE t (a INTEGER)"))); + QVERIFY(writer.exec(QStringLiteral("INSERT INTO t (a) VALUES (1)"))); + + Database reader; + QVERIFY(reader.open(path)); + + Transaction tx(writer); + QVERIFY(tx.isActive()); + QVERIFY(writer.exec(QStringLiteral("INSERT INTO t (a) VALUES (2)"))); + + // Uncommitted write is invisible to the reader, and the read succeeds + // rather than blocking until the busy timeout expires. + QCOMPARE(reader.scalar(QStringLiteral("SELECT count(*) FROM t")), 1LL); + + QVERIFY(tx.commit()); + QCOMPARE(reader.scalar(QStringLiteral("SELECT count(*) FROM t")), 2LL); +} + +QTEST_GUILESS_MAIN(TestDatabase) +#include "tst_database.moc" diff --git a/tests/tst_jsonutils.cpp b/tests/tst_jsonutils.cpp new file mode 100644 index 00000000..630f921d --- /dev/null +++ b/tests/tst_jsonutils.cpp @@ -0,0 +1,80 @@ +#include "jsonutils.h" + +#include + +using namespace jsonutils; + +class TestJsonUtils : public QObject { + Q_OBJECT + +private slots: + void readsPlainNumbers(); + void readsNumbersStoredAsStrings(); + void roundsFloatsStoredForInts(); + void readsBoolsInEveryFormThatWasWritten(); + void readsNumbersStoredAsStringsForBools(); + void stringifiesNumbers(); + void fallsBackWhenMissingOrJunk(); +}; + +void TestJsonUtils::readsPlainNumbers() +{ + QCOMPARE(getDouble(QJsonValue(1.5)), 1.5); + QCOMPARE(getFloat(QJsonValue(1.5)), 1.5f); + QCOMPARE(getInt(QJsonValue(3)), 3); + QCOMPARE(getLong(QJsonValue(2147483648.0)), 2147483648L); +} + +void TestJsonUtils::readsNumbersStoredAsStrings() +{ + // Old files wrote prop values through JS string conversion. + QCOMPARE(getDouble(QJsonValue(QStringLiteral("1.5"))), 1.5); + QCOMPARE(getDouble(QJsonValue(QStringLiteral(" -0.25 "))), -0.25); + QCOMPARE(getInt(QJsonValue(QStringLiteral("7"))), 7); + QCOMPARE(getFloat(QJsonValue(QStringLiteral("1e2"))), 100.0f); +} + +void TestJsonUtils::roundsFloatsStoredForInts() +{ + // An int prop saved as 3.0 (or 2.7 after a slider drag) must not truncate + // to something a step below what the user set. + QCOMPARE(getInt(QJsonValue(3.0)), 3); + QCOMPARE(getInt(QJsonValue(2.7)), 3); + QCOMPARE(getInt(QJsonValue(QStringLiteral("2.7"))), 3); + QCOMPARE(getInt(QJsonValue(-2.7)), -3); +} + +void TestJsonUtils::readsBoolsInEveryFormThatWasWritten() +{ + QCOMPARE(getBool(QJsonValue(true)), true); + // BoolProp::toJson still writes the string form. + QCOMPARE(getBool(QJsonValue(QStringLiteral("true"))), true); + QCOMPARE(getBool(QJsonValue(QStringLiteral("TRUE"))), true); + QCOMPARE(getBool(QJsonValue(QStringLiteral("false"))), false); + QCOMPARE(getBool(QJsonValue(1.0)), true); + QCOMPARE(getBool(QJsonValue(0.0)), false); +} + +void TestJsonUtils::readsNumbersStoredAsStringsForBools() +{ + QCOMPARE(getBool(QJsonValue(QStringLiteral("1"))), true); + QCOMPARE(getBool(QJsonValue(QStringLiteral("0"))), false); +} + +void TestJsonUtils::stringifiesNumbers() +{ + QCOMPARE(getString(QJsonValue(3.0)), QStringLiteral("3")); + QCOMPARE(getString(QJsonValue(QStringLiteral("abc"))), QStringLiteral("abc")); +} + +void TestJsonUtils::fallsBackWhenMissingOrJunk() +{ + QJsonObject obj; + QCOMPARE(getDouble(obj["missing"], 300.0), 300.0); + QCOMPARE(getFloat(QJsonValue(QStringLiteral("not a number")), 2.5f), 2.5f); + QCOMPARE(getBool(QJsonValue(QJsonValue::Null), true), true); + QCOMPARE(getInt(QJsonValue(QJsonArray()), 9), 9); +} + +QTEST_APPLESS_MAIN(TestJsonUtils) +#include "tst_jsonutils.moc" diff --git a/tests/tst_texturelistmodel.cpp b/tests/tst_texturelistmodel.cpp new file mode 100644 index 00000000..9d2a7ddb --- /dev/null +++ b/tests/tst_texturelistmodel.cpp @@ -0,0 +1,290 @@ +#include "catalogindex.h" +#include "texturelistmodel.h" + +#include +#include +#include + +using namespace catalog; + +namespace { + +constexpr qint64 kT0 = 1'700'000'000'000LL; + +TextureRecord makeRecord(const QString& name, int ordinal) +{ + TextureRecord rec; + rec.path = QStringLiteral("/tex/%1.texture").arg(name); + rec.name = name; + rec.fileSize = 1000 * (ordinal + 1); + rec.fileMtime = kT0 + qint64(ordinal) * 1000; + rec.width = 2048; + rec.height = 2048; + rec.nodeCount = 12; + rec.libVersion = QStringLiteral("v3"); + rec.channels = ChannelAlbedo | ChannelNormal; + return rec; +} + +} // namespace + +class TestTextureListModel : public QObject { + Q_OBJECT + +private slots: + void init(); + void cleanup(); + + void emptyWithoutIndex(); + void passesModelTester(); + + void exposesRolesWithoutFormatting(); + void reportsTotalSeparatelyFromRowCount(); + void pagesInWithFetchMore(); + + void filterSwitchesRowSet(); + void sortChangesOrder(); + void searchNarrowsRows(); + + void openPathDrivesIsOpenRole(); + void migrationBadgeOnlyForKnownOlderVersions(); + + void refreshPicksUpExternalChanges(); + +private: + void seed(int count); + + QScopedPointer dir; + QScopedPointer index; + QScopedPointer model; +}; + +void TestTextureListModel::init() +{ + dir.reset(new QTemporaryDir); + QVERIFY(dir->isValid()); + + index.reset(new CatalogIndex); + QVERIFY(index->open(dir->filePath(QStringLiteral("index.db")))); + + model.reset(new TextureListModel); +} + +void TestTextureListModel::cleanup() +{ + model.reset(); + index.reset(); + dir.reset(); +} + +void TestTextureListModel::seed(int count) +{ + for (int i = 0; i < count; ++i) { + TextureRecord rec = makeRecord(QStringLiteral("tex%1").arg(i, 4, 10, QLatin1Char('0')), i); + QVERIFY(index->recordOpened(rec, kT0 + qint64(i) * 1000)); + } +} + +void TestTextureListModel::emptyWithoutIndex() +{ + // The launcher builds its model before the catalog is necessarily open; + // that must be an empty grid, not a crash. + QCOMPARE(model->rowCount(), 0); + QCOMPARE(model->totalCount(), 0); + QVERIFY(!model->data(model->index(0), TextureListModel::NameRole).isValid()); + QVERIFY(!model->canFetchMore(QModelIndex())); +} + +void TestTextureListModel::passesModelTester() +{ + // Catches the whole class of index/rowCount/parent contract violations that + // otherwise surface as a view crash on someone's machine. + seed(10); + QAbstractItemModelTester tester(model.data(), QAbstractItemModelTester::FailureReportingMode::Warning); + model->setIndex(index.data()); + model->setFilter(Filter::Recents); + model->setSort(SortKey::Name, true); + model->setSearchTerm(QStringLiteral("tex000")); + model->setSearchTerm(QString()); + model->refresh(); + QVERIFY(model->rowCount() > 0); +} + +void TestTextureListModel::exposesRolesWithoutFormatting() +{ + seed(1); + model->setIndex(index.data()); + QCOMPARE(model->rowCount(), 1); + + const QModelIndex idx = model->index(0); + + // Raw values, not display strings: no "2h ago", no "2K". Formatting belongs + // to the delegate, so the grid and a future list view can differ. + QCOMPARE(idx.data(TextureListModel::NameRole).toString(), QStringLiteral("tex0000")); + QCOMPARE(idx.data(TextureListModel::ModifiedRole).toLongLong(), kT0); + QCOMPARE(idx.data(TextureListModel::WidthRole).toInt(), 2048); + QCOMPARE(idx.data(TextureListModel::HeightRole).toInt(), 2048); + QCOMPARE(idx.data(TextureListModel::NodeCountRole).toInt(), 12); + QCOMPARE(idx.data(TextureListModel::ChannelsRole).toInt(), int(ChannelAlbedo | ChannelNormal)); + QCOMPARE(idx.data(TextureListModel::StarredRole).toBool(), false); + QCOMPARE(idx.data(TextureListModel::MissingRole).toBool(), false); + QCOMPARE(idx.data(Qt::ToolTipRole).toString(), QStringLiteral("/tex/tex0000.texture")); + + const TextureRecord rec = model->recordAt(idx); + QVERIFY(rec.isValid()); + QCOMPARE(model->indexForId(rec.id).row(), 0); +} + +void TestTextureListModel::reportsTotalSeparatelyFromRowCount() +{ + // The empty-state copy keys off totalCount(), which must describe the whole + // result set — not just the page that happens to be resident. + seed(TextureListModel::PageSize + 25); + model->setIndex(index.data()); + + QCOMPARE(model->rowCount(), TextureListModel::PageSize); + QCOMPARE(model->totalCount(), TextureListModel::PageSize + 25); +} + +void TestTextureListModel::pagesInWithFetchMore() +{ + const int extra = 25; + seed(TextureListModel::PageSize + extra); + model->setIndex(index.data()); + + QVERIFY(model->canFetchMore(QModelIndex())); + + QSignalSpy inserted(model.data(), &QAbstractItemModel::rowsInserted); + model->fetchMore(QModelIndex()); + + QCOMPARE(inserted.count(), 1); + QCOMPARE(model->rowCount(), TextureListModel::PageSize + extra); + QVERIFY(!model->canFetchMore(QModelIndex())); + + // Every row is distinct — an off-by-one in the offset would duplicate the + // page boundary, which looks like a rendering glitch rather than a bug. + QSet paths; + for (int row = 0; row < model->rowCount(); ++row) + paths.insert(model->index(row).data(TextureListModel::PathRole).toString()); + QCOMPARE(paths.size(), model->rowCount()); +} + +void TestTextureListModel::filterSwitchesRowSet() +{ + seed(3); + + // One saved but never opened, one starred. + TextureRecord savedOnly = makeRecord(QStringLiteral("savedonly"), 99); + QVERIFY(index->recordSaved(savedOnly, kT0)); + QVERIFY(index->setStarred(index->byPath(QStringLiteral("/tex/tex0000.texture")).id, true)); + + model->setIndex(index.data()); + QCOMPARE(model->totalCount(), 4); + + model->setFilter(Filter::Recents); + QCOMPARE(model->totalCount(), 3); // the save-only row is not a "recent" + + model->setFilter(Filter::Starred); + QCOMPARE(model->totalCount(), 1); + + model->setFilter(Filter::All); + QCOMPARE(model->totalCount(), 4); +} + +void TestTextureListModel::sortChangesOrder() +{ + seed(4); + model->setIndex(index.data()); + + model->setSort(SortKey::Name, true); + QCOMPARE(model->index(0).data(TextureListModel::NameRole).toString(), + QStringLiteral("tex0000")); + + model->setSort(SortKey::Name, false); + QCOMPARE(model->index(0).data(TextureListModel::NameRole).toString(), + QStringLiteral("tex0003")); + + model->setSort(SortKey::Modified, false); + QCOMPARE(model->index(0).data(TextureListModel::ModifiedRole).toLongLong(), kT0 + 3000); + + model->setSort(SortKey::Size, false); + QCOMPARE(model->index(0).data(TextureListModel::FileSizeRole).toLongLong(), 4000LL); +} + +void TestTextureListModel::searchNarrowsRows() +{ + seed(3); + model->setIndex(index.data()); + QCOMPARE(model->totalCount(), 3); + + model->setSearchTerm(QStringLiteral("tex0001")); + QCOMPARE(model->totalCount(), 1); + QCOMPARE(model->rowCount(), 1); + + model->setSearchTerm(QString()); + QCOMPARE(model->totalCount(), 3); +} + +void TestTextureListModel::openPathDrivesIsOpenRole() +{ + seed(2); + model->setIndex(index.data()); + model->setSort(SortKey::Name, true); + + QVERIFY(!model->index(0).data(TextureListModel::IsOpenRole).toBool()); + + QSignalSpy changed(model.data(), &QAbstractItemModel::dataChanged); + model->setOpenPath(QStringLiteral("/tex/tex0000.texture")); + + QCOMPARE(changed.count(), 1); + QVERIFY(model->index(0).data(TextureListModel::IsOpenRole).toBool()); + QVERIFY(!model->index(1).data(TextureListModel::IsOpenRole).toBool()); +} + +void TestTextureListModel::migrationBadgeOnlyForKnownOlderVersions() +{ + TextureRecord current = makeRecord(QStringLiteral("current"), 0); + current.libVersion = QStringLiteral("v3"); + QVERIFY(index->recordOpened(current, kT0)); + + TextureRecord old = makeRecord(QStringLiteral("old"), 1); + old.libVersion = QStringLiteral("v1"); + QVERIFY(index->recordOpened(old, kT0)); + + // Seeded from the recents list: we've never looked inside it. + TextureRecord unknown = makeRecord(QStringLiteral("unknown"), 2); + unknown.libVersion.clear(); + QVERIFY(index->recordOpened(unknown, kT0)); + + model->setIndex(index.data()); + model->setCurrentLibVersion(QStringLiteral("v3")); + model->setSort(SortKey::Name, true); + + auto badge = [this](int row) { + return model->index(row).data(TextureListModel::NeedsMigrationRole).toBool(); + }; + + QVERIFY(!badge(0)); // "current" + QVERIFY(badge(1)); // "old" + QVERIFY(!badge(2)); // "unknown" — absence of data is not evidence of age +} + +void TestTextureListModel::refreshPicksUpExternalChanges() +{ + seed(2); + model->setIndex(index.data()); + QCOMPARE(model->totalCount(), 2); + + TextureRecord added = makeRecord(QStringLiteral("added"), 5); + QVERIFY(index->recordOpened(added, kT0 + 99'000)); + + // The model doesn't watch the database; CatalogService::catalogChanged is + // what drives this in the app. + QCOMPARE(model->totalCount(), 2); + + model->refresh(); + QCOMPARE(model->totalCount(), 3); +} + +QTEST_MAIN(TestTextureListModel) +#include "tst_texturelistmodel.moc" diff --git a/tests/tst_thumbnailcache.cpp b/tests/tst_thumbnailcache.cpp new file mode 100644 index 00000000..4968edf4 --- /dev/null +++ b/tests/tst_thumbnailcache.cpp @@ -0,0 +1,339 @@ +#include "thumbnailcache.h" + +#include +#include +#include +#include +#include +#include + +using namespace catalog; + +namespace { + +constexpr qint64 kT0 = 1'700'000'000'000LL; + +// Stand-in for an encoded JPEG. Content doesn't matter, only that it round +// trips byte for byte — a BLOB column that mangles data would be silent. +QByteArray fakeImage(int sizeBytes, char fill = 'x') +{ + return QByteArray(sizeBytes, fill); +} + +ThumbKey keyFor(qint64 textureId, int size = 256) +{ + ThumbKey key; + key.textureId = textureId; + key.size = size; + return key; +} + +} // namespace + +class TestThumbnailCache : public QObject { + Q_OBJECT + +private slots: + void init(); + void cleanup(); + + void createsSchemaWithTunedPragmas(); + void putGetRoundTripsExactBytes(); + void realJpegSurvivesStoreAndDecode(); + void missReturnsEmpty(); + void sizeAndMeshArePartOfTheKey(); + void putOverwritesExistingVariant(); + void putRejectsEmptyOrUnkeyedImages(); + void putBatchWritesAll(); + + void removeTextureDropsEveryVariant(); + + void getDoesNotWriteLastUsed(); + void touchUpdatesLastUsed(); + + void evictionRemovesLeastRecentlyUsedFirst(); + void evictionIsNoOpUnderBudget(); + + void rebuildsWhenSchemaVersionDiffers(); + void rebuildsWhenFileIsCorrupt(); + void deletingFileWhileClosedIsRecoverable(); + +private: + QString cachePath() const { return dir->filePath(QStringLiteral("thumbs.db")); } + + QScopedPointer dir; + QScopedPointer cache; +}; + +void TestThumbnailCache::init() +{ + dir.reset(new QTemporaryDir); + QVERIFY(dir->isValid()); + cache.reset(new ThumbnailCache); + QVERIFY(cache->open(cachePath())); +} + +void TestThumbnailCache::cleanup() +{ + cache.reset(); + dir.reset(); +} + +void TestThumbnailCache::createsSchemaWithTunedPragmas() +{ + QVERIFY(cache->isOpen()); + QCOMPARE(cache->rowCount(), 0); + QCOMPARE(cache->totalBytes(), 0LL); + + // Both of these can only be set on an empty file, so if the schema were + // ever created before the PRAGMAs they'd silently revert to the defaults + // and incremental_vacuum would become a no-op. + Database raw; + QVERIFY(raw.open(cachePath())); + QCOMPARE(raw.scalar(QStringLiteral("PRAGMA page_size")), 8192LL); + QCOMPARE(raw.scalar(QStringLiteral("PRAGMA auto_vacuum")), 2LL); +} + +void TestThumbnailCache::putGetRoundTripsExactBytes() +{ + const QByteArray image = fakeImage(4096, '\x1'); + const ThumbKey key = keyFor(1); + + QVERIFY(cache->put(key, image, ThumbSource::Save, kT0)); + QVERIFY(cache->contains(key)); + QCOMPARE(cache->get(key), image); + QCOMPARE(cache->rowCount(), 1); + QCOMPARE(cache->totalBytes(), 4096LL); +} + +void TestThumbnailCache::realJpegSurvivesStoreAndDecode() +{ + // The capture path end to end minus the GL grab: encode a real image the + // way CatalogService::captureThumbnail does, store it, then decode it back + // the way the model does. A BLOB column that truncated or re-encoded would + // show up as a garbled card, which is hard to attribute after the fact. + QImage source(256, 256, QImage::Format_RGB32); + for (int y = 0; y < source.height(); ++y) + for (int x = 0; x < source.width(); ++x) + source.setPixel(x, y, qRgb(x, y, (x ^ y) & 0xFF)); + + QByteArray encoded; + QBuffer buffer(&encoded); + QVERIFY(buffer.open(QIODevice::WriteOnly)); + QVERIFY(source.save(&buffer, "JPG", 85)); + QVERIFY(!encoded.isEmpty()); + + const ThumbKey key = keyFor(7); + QVERIFY(cache->put(key, encoded, ThumbSource::Save, kT0)); + + const QByteArray fetched = cache->get(key); + QCOMPARE(fetched, encoded); + + // QImage, not QPixmap: a pixmap needs a QGuiApplication and this suite runs + // guiless. The decode path is what's under test either way. + QImage back; + QVERIFY(back.loadFromData(fetched, "JPG")); + QCOMPARE(back.size(), QSize(256, 256)); + + // Lossy, so compare structure rather than exact pixels: a black or + // transposed image would fail this while surviving a byte comparison. + QVERIFY(qAbs(qRed(back.pixel(200, 10)) - 200) < 24); + QVERIFY(qAbs(qGreen(back.pixel(10, 200)) - 200) < 24); +} + +void TestThumbnailCache::missReturnsEmpty() +{ + QVERIFY(cache->get(keyFor(2)).isEmpty()); + QVERIFY(!cache->contains(keyFor(2))); + + // An unkeyed request is a miss, not a crash. + QVERIFY(cache->get(ThumbKey()).isEmpty()); + QVERIFY(!cache->contains(ThumbKey())); +} + +void TestThumbnailCache::sizeAndMeshArePartOfTheKey() +{ + QVERIFY(cache->put(keyFor(1, 256), fakeImage(100, 'a'), ThumbSource::Save, kT0)); + QVERIFY(cache->put(keyFor(1, 512), fakeImage(200, 'b'), ThumbSource::Save, kT0)); + + ThumbKey otherMesh = keyFor(1, 256); + otherMesh.mesh = QStringLiteral("cube"); + QVERIFY(cache->put(otherMesh, fakeImage(300, 'c'), ThumbSource::Save, kT0)); + + QCOMPARE(cache->rowCount(), 3); + QCOMPARE(cache->get(keyFor(1, 256)).at(0), 'a'); + QCOMPARE(cache->get(keyFor(1, 512)).at(0), 'b'); + QCOMPARE(cache->get(otherMesh).at(0), 'c'); +} + +void TestThumbnailCache::putOverwritesExistingVariant() +{ + const ThumbKey key = keyFor(1); + QVERIFY(cache->put(key, fakeImage(100, 'a'), ThumbSource::Open, kT0)); + QVERIFY(cache->put(key, fakeImage(200, 'b'), ThumbSource::Save, kT0 + 1000)); + + // A better capture supersedes a cheaper one rather than adding a row. + QCOMPARE(cache->rowCount(), 1); + QCOMPARE(cache->get(key).size(), 200); + QCOMPARE(cache->get(key).at(0), 'b'); +} + +void TestThumbnailCache::putRejectsEmptyOrUnkeyedImages() +{ + QVERIFY(!cache->put(keyFor(1), QByteArray(), ThumbSource::Save, kT0)); + QVERIFY(!cache->put(ThumbKey(), fakeImage(100), ThumbSource::Save, kT0)); + QCOMPARE(cache->rowCount(), 0); +} + +void TestThumbnailCache::putBatchWritesAll() +{ + // One transaction per thumbnail means one fsync per thumbnail; the batch + // path exists so a bulk write doesn't crawl. + QVector entries; + for (int i = 0; i < 25; ++i) { + ThumbnailCache::Entry entry; + entry.key = keyFor(i); + entry.bytes = fakeImage(512); + entries << entry; + } + + QVERIFY(cache->putBatch(entries, kT0)); + QCOMPARE(cache->rowCount(), 25); + QCOMPARE(cache->totalBytes(), 25LL * 512); +} + +void TestThumbnailCache::removeTextureDropsEveryVariant() +{ + // How a stale thumbnail is invalidated now that there's no content hash: + // reconciliation sees size or mtime differ and drops the texture's images + // outright. Every variant goes, not just the size that happened to be on + // screen. + QVERIFY(cache->put(keyFor(1, 256), fakeImage(100), ThumbSource::Save, kT0)); + QVERIFY(cache->put(keyFor(1, 512), fakeImage(100), ThumbSource::Save, kT0)); + QVERIFY(cache->put(keyFor(2, 256), fakeImage(100), ThumbSource::Save, kT0)); + + QCOMPARE(cache->removeTexture(1), 2); + QCOMPARE(cache->rowCount(), 1); + QVERIFY(cache->contains(keyFor(2, 256))); + + QCOMPARE(cache->removeTexture(-1), 0); + QCOMPARE(cache->removeTexture(999), 0); +} + +void TestThumbnailCache::getDoesNotWriteLastUsed() +{ + // get() runs during scroll. A write per painted card is exactly what the + // "no disk writes on the GUI thread" rule forbids, so reads must be pure. + const ThumbKey key = keyFor(1); + QVERIFY(cache->put(key, fakeImage(100), ThumbSource::Save, kT0)); + + Database raw; + QVERIFY(raw.open(cachePath())); + const qint64 before = raw.scalar(QStringLiteral("SELECT last_used FROM thumb")); + + for (int i = 0; i < 10; ++i) + QVERIFY(!cache->get(key).isEmpty()); + + QCOMPARE(raw.scalar(QStringLiteral("SELECT last_used FROM thumb")), before); +} + +void TestThumbnailCache::touchUpdatesLastUsed() +{ + const ThumbKey key = keyFor(1); + QVERIFY(cache->put(key, fakeImage(100), ThumbSource::Save, kT0)); + + QVERIFY(cache->touch({key}, kT0 + 86'400'000)); + + Database raw; + QVERIFY(raw.open(cachePath())); + QCOMPARE(raw.scalar(QStringLiteral("SELECT last_used FROM thumb")), kT0 + 86'400'000); + + QVERIFY(cache->touch({}, kT0)); // empty batch is fine +} + +void TestThumbnailCache::evictionRemovesLeastRecentlyUsedFirst() +{ + // Ten 10 KiB images, each used a day apart. + for (int i = 0; i < 10; ++i) { + const ThumbKey key = keyFor(i); + QVERIFY(cache->put(key, fakeImage(10 * 1024), ThumbSource::Save, + kT0 + qint64(i) * 86'400'000)); + } + QCOMPARE(cache->totalBytes(), 10LL * 10 * 1024); + + // Trim to roughly half. + const int deleted = cache->evictTo(50 * 1024); + QCOMPARE(deleted, 5); + QCOMPARE(cache->rowCount(), 5); + QVERIFY(cache->totalBytes() <= 50 * 1024); + + // The five that survived are the five most recently used. + for (int i = 0; i < 5; ++i) + QVERIFY(!cache->contains(keyFor(i))); + for (int i = 5; i < 10; ++i) + QVERIFY(cache->contains(keyFor(i))); +} + +void TestThumbnailCache::evictionIsNoOpUnderBudget() +{ + QVERIFY(cache->put(keyFor(1), fakeImage(1024), ThumbSource::Save, kT0)); + + QCOMPARE(cache->evictTo(ThumbnailCache::DefaultBudgetBytes), 0); + QCOMPARE(cache->rowCount(), 1); +} + +void TestThumbnailCache::rebuildsWhenSchemaVersionDiffers() +{ + // A cache has no history worth migrating, so a schema from another build + // is thrown away rather than upgraded. This is the difference that makes + // thumbs.db disposable and index.db not. + QVERIFY(cache->put(keyFor(1), fakeImage(1024), ThumbSource::Save, kT0)); + cache->close(); + + { + Database raw; + QVERIFY(raw.open(cachePath())); + QVERIFY(raw.exec(QStringLiteral("UPDATE meta SET v = '99' WHERE k = 'schema_version'"))); + } + + QVERIFY(cache->open(cachePath())); + QVERIFY(cache->isOpen()); + QCOMPARE(cache->rowCount(), 0); + + // And it's usable immediately afterwards. + QVERIFY(cache->put(keyFor(1), fakeImage(1024), ThumbSource::Save, kT0)); + QCOMPARE(cache->rowCount(), 1); +} + +void TestThumbnailCache::rebuildsWhenFileIsCorrupt() +{ + cache->close(); + + { + QFile file(cachePath()); + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Truncate)); + file.write("this is not a database, it is a picture of a database"); + } + + QVERIFY(cache->open(cachePath())); + QVERIFY(cache->isOpen()); + QVERIFY(cache->put(keyFor(1), fakeImage(1024), ThumbSource::Save, kT0)); + QCOMPARE(cache->rowCount(), 1); +} + +void TestThumbnailCache::deletingFileWhileClosedIsRecoverable() +{ + // "Deleting thumbs.db degrades gracefully" from the acceptance criteria. + QVERIFY(cache->put(keyFor(1), fakeImage(1024), ThumbSource::Save, kT0)); + cache->close(); + + QVERIFY(QFile::remove(cachePath())); + + QVERIFY(cache->open(cachePath())); + QCOMPARE(cache->rowCount(), 0); + QVERIFY(cache->get(keyFor(1)).isEmpty()); + QVERIFY(cache->put(keyFor(1), fakeImage(1024), ThumbSource::Save, kT0)); +} + +QTEST_GUILESS_MAIN(TestThumbnailCache) +#include "tst_thumbnailcache.moc" diff --git a/tests/tst_updateendpoint.cpp b/tests/tst_updateendpoint.cpp new file mode 100644 index 00000000..a5e7e14e --- /dev/null +++ b/tests/tst_updateendpoint.cpp @@ -0,0 +1,141 @@ +#include "updatechecker.h" + +#include "telemetry.h" + +#include +#include +#include +#include + +// UpdateChecker leaves breadcrumbs; the real Telemetry pulls in Sentry and a +// generated version header, neither of which this test has an opinion about. +// Stubbing the one entry point it uses keeps the test to the code under test. +namespace Telemetry { +void breadcrumb(const char*, const std::string&) {} +} // namespace Telemetry + +// Covers what decides *which server gets asked* and on what channel — the +// question you have when a dev server sees no traffic. The endpoint itself is +// hardcoded, so the interesting assertions are that it stays well-formed and +// that nothing outside the source can redirect it. +class TestUpdateEndpoint : public QObject { + Q_OBJECT + +private slots: + void initTestCase(); + void cleanup(); + + void endpointIsAWellFormedAbsoluteUrl(); + void endpointIgnoresTheEnvironment(); + + void channelFollowsOwnVersion(); + void channelSettingWinsOverVersion(); + void nonsenseChannelSettingIsIgnored(); + + void remembersAKnownUpdateAcrossRestarts(); + void forgetsAKnownUpdateOnceTheBuildCatchesUp(); +}; + +void TestUpdateEndpoint::initTestCase() +{ + // Keep every settings write inside the test's own scope rather than the + // developer's real config. + QStandardPaths::setTestModeEnabled(true); +} + +void TestUpdateEndpoint::cleanup() +{ + qunsetenv("TEXTURELAB_API_BASE"); + + QSettings settings(QSettings::UserScope, "texturelab", "texturelab"); + settings.remove("updateChannel"); + settings.remove("updateKnownVersion"); +} + +void TestUpdateEndpoint::endpointIsAWellFormedAbsoluteUrl() +{ + // The request path is appended directly, so the base has to be absolute and + // free of a trailing slash — "…:3333/" + "/api/…" is a double-slashed path + // that some routers answer with a 404. + const QString base = UpdateChecker::apiBase(); + + QVERIFY(!base.isEmpty()); + QVERIFY(base.startsWith(QStringLiteral("http"))); + QVERIFY(!base.endsWith(QLatin1Char('/'))); + + const QUrl url(base + QStringLiteral("/api/releases/latest")); + QVERIFY(url.isValid()); + QCOMPARE(url.path(), QStringLiteral("/api/releases/latest")); +} + +void TestUpdateEndpoint::endpointIgnoresTheEnvironment() +{ + // The endpoint is hardcoded. It used to be overridable, and this asserts + // the override is really gone rather than quietly still honoured — a stale + // variable in someone's shell would otherwise redirect update checks. + const QString before = UpdateChecker::apiBase(); + + qputenv("TEXTURELAB_API_BASE", "http://example.invalid:1234"); + QCOMPARE(UpdateChecker::apiBase(), before); +} + +void TestUpdateEndpoint::channelFollowsOwnVersion() +{ + QCoreApplication::setApplicationVersion(QStringLiteral("0.4.0-beta+abc1234")); + QCOMPARE(UpdateChecker::channel(), QStringLiteral("beta")); + + QCoreApplication::setApplicationVersion(QStringLiteral("1.0.0+abc1234")); + QCOMPARE(UpdateChecker::channel(), QStringLiteral("stable")); +} + +void TestUpdateEndpoint::channelSettingWinsOverVersion() +{ + QCoreApplication::setApplicationVersion(QStringLiteral("0.4.0-beta")); + QSettings(QSettings::UserScope, "texturelab", "texturelab") + .setValue(QStringLiteral("updateChannel"), QStringLiteral("stable")); + + QCOMPARE(UpdateChecker::channel(), QStringLiteral("stable")); +} + +void TestUpdateEndpoint::nonsenseChannelSettingIsIgnored() +{ + QCoreApplication::setApplicationVersion(QStringLiteral("0.4.0-beta")); + QSettings(QSettings::UserScope, "texturelab", "texturelab") + .setValue(QStringLiteral("updateChannel"), QStringLiteral("nightly")); + + // The API only accepts stable|beta; anything else would come back 422. + QCOMPARE(UpdateChecker::channel(), QStringLiteral("beta")); +} + +void TestUpdateEndpoint::remembersAKnownUpdateAcrossRestarts() +{ + // The launcher shows the notice from this, not from a fresh request — the + // network check is throttled to once every few hours, and without a + // remembered answer the notice would vanish in between. + QCoreApplication::setApplicationVersion(QStringLiteral("0.4.0-beta+abc1234")); + + QSettings settings(QSettings::UserScope, "texturelab", "texturelab"); + settings.setValue(QStringLiteral("updateKnownVersion"), QStringLiteral("1.3.0-beta")); + + QCOMPARE(UpdateChecker::knownUpdateVersion(), QStringLiteral("1.3.0-beta")); +} + +void TestUpdateEndpoint::forgetsAKnownUpdateOnceTheBuildCatchesUp() +{ + QSettings settings(QSettings::UserScope, "texturelab", "texturelab"); + settings.setValue(QStringLiteral("updateKnownVersion"), QStringLiteral("1.3.0-beta")); + + // Running exactly the remembered version: nothing left to announce. + QCoreApplication::setApplicationVersion(QStringLiteral("1.3.0-beta+deadbee")); + QVERIFY(UpdateChecker::knownUpdateVersion().isEmpty()); + + // And past it, which is what a beta tester hits after installing. + QCoreApplication::setApplicationVersion(QStringLiteral("1.4.0")); + QVERIFY(UpdateChecker::knownUpdateVersion().isEmpty()); + + settings.remove(QStringLiteral("updateKnownVersion")); + QVERIFY(UpdateChecker::knownUpdateVersion().isEmpty()); +} + +QTEST_GUILESS_MAIN(TestUpdateEndpoint) +#include "tst_updateendpoint.moc" diff --git a/tests/tst_versioncompare.cpp b/tests/tst_versioncompare.cpp new file mode 100644 index 00000000..03b87e11 --- /dev/null +++ b/tests/tst_versioncompare.cpp @@ -0,0 +1,104 @@ +#include "versioncompare.h" + +#include + +using namespace appversion; + +class TestVersionCompare : public QObject { + Q_OBJECT + +private slots: + void normalizeStripsBuildMetadataAndPrefix(); + + void comparesNumericParts(); + void treatsMissingPartsAsZero(); + void preReleaseSortsBelowRelease(); + void comparesPreReleaseIdentifiers(); + + void thisBuildIsNotNewerThanItself(); + void betaBuildSeesMatchingStableRelease(); + void malformedInputNeverClaimsAnUpdate(); +}; + +void TestVersionCompare::normalizeStripsBuildMetadataAndPrefix() +{ + // The app's own version is "0.4.0-beta+"; the API publishes + // "0.4.0-beta". Without stripping the metadata every check would either + // compare unequal strings or mis-parse the hash as a version part. + QCOMPARE(normalize(QStringLiteral("0.4.0-beta+a1b2c3d")), QStringLiteral("0.4.0-beta")); + QCOMPARE(normalize(QStringLiteral("v1.2.3")), QStringLiteral("1.2.3")); + QCOMPARE(normalize(QStringLiteral(" 1.2.3 ")), QStringLiteral("1.2.3")); +} + +void TestVersionCompare::comparesNumericParts() +{ + QCOMPARE(compare(QStringLiteral("1.0.0"), QStringLiteral("1.0.0")), 0); + QVERIFY(isNewer(QStringLiteral("1.0.1"), QStringLiteral("1.0.0"))); + QVERIFY(isNewer(QStringLiteral("1.1.0"), QStringLiteral("1.0.9"))); + QVERIFY(isNewer(QStringLiteral("2.0.0"), QStringLiteral("1.9.9"))); + QVERIFY(!isNewer(QStringLiteral("1.0.0"), QStringLiteral("1.0.1"))); + + // Not a string comparison: "0.10.0" is newer than "0.9.0" even though it + // sorts lower lexically. This is the classic way this goes wrong. + QVERIFY(isNewer(QStringLiteral("0.10.0"), QStringLiteral("0.9.0"))); + QVERIFY(isNewer(QStringLiteral("0.4.10"), QStringLiteral("0.4.9"))); +} + +void TestVersionCompare::treatsMissingPartsAsZero() +{ + QCOMPARE(compare(QStringLiteral("1.2"), QStringLiteral("1.2.0")), 0); + QCOMPARE(compare(QStringLiteral("1"), QStringLiteral("1.0.0")), 0); + QVERIFY(isNewer(QStringLiteral("1.2.1"), QStringLiteral("1.2"))); +} + +void TestVersionCompare::preReleaseSortsBelowRelease() +{ + // Semver's rule, and the one that matters most here: shipping 0.4.0 final + // must register as an update for someone on 0.4.0-beta. + QVERIFY(isNewer(QStringLiteral("0.4.0"), QStringLiteral("0.4.0-beta"))); + QVERIFY(!isNewer(QStringLiteral("0.4.0-beta"), QStringLiteral("0.4.0"))); + QCOMPARE(compare(QStringLiteral("0.4.0-beta"), QStringLiteral("0.4.0-beta")), 0); +} + +void TestVersionCompare::comparesPreReleaseIdentifiers() +{ + QVERIFY(isNewer(QStringLiteral("1.0.0-beta.2"), QStringLiteral("1.0.0-beta.1"))); + QVERIFY(isNewer(QStringLiteral("1.0.0-beta.10"), QStringLiteral("1.0.0-beta.9"))); + QVERIFY(isNewer(QStringLiteral("1.0.0-rc"), QStringLiteral("1.0.0-beta"))); + + // A longer run of otherwise-equal identifiers is the higher version. + QVERIFY(isNewer(QStringLiteral("1.0.0-beta.1"), QStringLiteral("1.0.0-beta"))); + + // Numeric identifiers rank below alphanumeric ones. + QVERIFY(isNewer(QStringLiteral("1.0.0-alpha"), QStringLiteral("1.0.0-1"))); +} + +void TestVersionCompare::thisBuildIsNotNewerThanItself() +{ + // The exact shape the app compares at runtime: its own version string, with + // the build hash attached, against what the API would publish for it. + const QString running = QStringLiteral("0.4.0-beta+e759268"); + QVERIFY(!isNewer(QStringLiteral("0.4.0-beta"), running)); + QCOMPARE(compare(QStringLiteral("0.4.0-beta"), running), 0); +} + +void TestVersionCompare::betaBuildSeesMatchingStableRelease() +{ + const QString running = QStringLiteral("0.4.0-beta+e759268"); + QVERIFY(isNewer(QStringLiteral("0.4.0"), running)); + QVERIFY(isNewer(QStringLiteral("0.5.0-beta"), running)); +} + +void TestVersionCompare::malformedInputNeverClaimsAnUpdate() +{ + // A broken response should mean "no update", never a false prompt and never + // a crash. + const QString running = QStringLiteral("0.4.0-beta+e759268"); + QVERIFY(!isNewer(QString(), running)); + QVERIFY(!isNewer(QStringLiteral("not-a-version"), running)); + QVERIFY(!isNewer(QStringLiteral("...."), running)); + QVERIFY(!isNewer(QStringLiteral("0.0.0"), running)); +} + +QTEST_GUILESS_MAIN(TestVersionCompare) +#include "tst_versioncompare.moc"