diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1d6bb802..cfcb5d81 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,6 +6,11 @@ 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: build-linux: @@ -23,7 +28,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 +43,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 +90,9 @@ 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 # Create desktop file cat > texturelab.desktop << 'EOF' @@ -73,6 +107,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 \ @@ -88,7 +127,7 @@ jobs: path: "*.AppImage" build-windows: - runs-on: windows-latest + runs-on: windows-2022 steps: - name: Checkout repository @@ -101,23 +140,61 @@ 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: | - 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 + 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 - name: Upload Windows artifact uses: actions/upload-artifact@v4 @@ -126,7 +203,10 @@ jobs: path: deploy/ build-macos: - runs-on: macos-latest + # 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 +216,141 @@ 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 path: build/src/texturelab/texturelab.app + + deploy: + needs: [build-linux, build-windows, build-macos] + if: always() + runs-on: ubuntu-latest + 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 + SHA: ${{ github.sha }} + run: | + cd artifacts + if [ -d texturelab-linux ]; then zip -r ../linux-$SHA.zip texturelab-linux/; fi + if [ -d texturelab-windows ]; then zip -r ../windows-$SHA.zip texturelab-windows/; fi + if [ -d texturelab-macos ]; then zip -r ../macos-$SHA.zip texturelab-macos/; fi + cd .. + for f in linux-$SHA.zip windows-$SHA.zip macos-$SHA.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 }} + SHA: ${{ github.sha }} + 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: | + SHORT_SHA="${SHA:0:7}" + 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 @ $SHORT_SHA" + COLOR=3066993 + else + TITLE="Build failed — $BRANCH @ $SHORT_SHA" + COLOR=15158332 + fi + + DESCRIPTION="$(download_line "$LINUX_RESULT" "Linux" "$S3_BASE/linux-$SHA.zip")\n$(download_line "$WINDOWS_RESULT" "Windows" "$S3_BASE/windows-$SHA.zip")\n$(download_line "$MACOS_RESULT" "macOS" "$S3_BASE/macos-$SHA.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..fd3d45a5 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) diff --git a/gh-build.sh b/gh-build.sh new file mode 100755 index 00000000..c27b2c22 --- /dev/null +++ b/gh-build.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +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..f4592af3 160000 --- a/public/assets +++ b/public/assets @@ -1 +1 @@ -Subproject commit 5996a27d943382fabeafb18217f9de212c62d420 +Subproject commit f4592af3f371674831f2c86178f16f0cf431d2f9 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/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/nodegraph/graph/frame.cpp b/src/nodegraph/graph/frame.cpp index 3845f2c6..f46f91a7 100644 --- a/src/nodegraph/graph/frame.cpp +++ b/src/nodegraph/graph/frame.cpp @@ -204,8 +204,15 @@ void Frame::paint(QPainter* painter, const QStyleOptionGraphicsItem* option, // Draw title if enabled if (_showTitle && !_title.isEmpty()) { + QFont font("Arial", 10, QFont::Bold); + painter->setFont(font); + + // shadow pass + painter->setPen(QColor(0, 0, 0, 160)); + painter->drawText(handleRect.translated(1, 1), Qt::AlignCenter, _title); + + // text pass painter->setPen(QColor(255, 255, 255)); - painter->setFont(QFont("Arial", 10, QFont::Bold)); painter->drawText(handleRect, Qt::AlignCenter, _title); } diff --git a/src/nodegraph/graph/scene.cpp b/src/nodegraph/graph/scene.cpp index 6d8c96fa..90c1dd41 100644 --- a/src/nodegraph/graph/scene.cpp +++ b/src/nodegraph/graph/scene.cpp @@ -5,8 +5,8 @@ #include #include #include -#include #include +#include #include #include #include @@ -25,14 +25,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 +46,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; } @@ -216,6 +227,7 @@ Node::Node() width = NODE_WIDTH; height = NODE_HEIGHT; isHovered = false; + showingSocketNames = false; defaultBorderColor = QColor(0, 0, 0); highlightBorderColor = QColor(0, 0, 0); @@ -250,6 +262,16 @@ Node::Node() font.setPixelSize(12); text->setFont(font); + channelText = new QGraphicsTextItem(this); + channelText->setFlag(QGraphicsItem::ItemIsFocusable, false); + channelText->setFlag(QGraphicsItem::ItemIsSelectable, false); + channelText->setDefaultTextColor(QColor(200, 255, 200)); + 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); @@ -264,11 +286,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 +325,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; } @@ -464,6 +515,17 @@ void Node::paint(QPainter* painter, QStyleOptionGraphicsItem const* option, painter->fillPath(bgPath, QBrush(QColor(10, 10, 10, 255))); if (!thumbnail.isNull()) { + // Checkerboard background for alpha-transparent thumbnails + static QPixmap checkerTile; + if (checkerTile.isNull()) { + checkerTile = QPixmap(16, 16); + checkerTile.fill(QColor(0xC0, 0xC0, 0xC0)); + QPainter cp(&checkerTile); + cp.fillRect(0, 0, 8, 8, QColor(0x80, 0x80, 0x80)); + cp.fillRect(8, 8, 8, 8, QColor(0x80, 0x80, 0x80)); + } + painter->fillRect(QRect(0, 0, nodeWidth, nodeHeight), + QBrush(checkerTile)); painter->drawPixmap(QRect(0, 0, nodeWidth, nodeHeight), thumbnail); } @@ -474,74 +536,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); } @@ -563,6 +648,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(QColor(0, 0, 0, 160)); + painter->drawRoundedRect(bgRect, 3, 3); + + painter->setPen(QColor(255, 255, 255, 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() diff --git a/src/nodegraph/graph/scene.h b/src/nodegraph/graph/scene.h index 052e05e0..11449c4c 100644 --- a/src/nodegraph/graph/scene.h +++ b/src/nodegraph/graph/scene.h @@ -80,11 +80,14 @@ class Node : public QGraphicsObject, public QEnableSharedFromThis { GLuint texId = 0; QGraphicsTextItem* text; + QGraphicsTextItem* channelText; QString name; + QString channel; QPixmap thumbnail; bool isHovered; + bool showingSocketNames; // bool isSelected; QColor defaultBorderColor; @@ -114,9 +117,13 @@ class Node : public QGraphicsObject, public QEnableSharedFromThis { const QVector getOutPorts() const; void setName(QString name); + void setChannel(QString ch); void setCenter(float x, float y); + QPointF getCenter() const; void setThumbnail(const QPixmap& pixmap); + void setShowSocketNames(bool show); + void addInPort(QString name); void addOutPort(QString name); diff --git a/src/nodegraph/nodegraph.cpp b/src/nodegraph/nodegraph.cpp index f71c4746..dfffa61e 100644 --- a/src/nodegraph/nodegraph.cpp +++ b/src/nodegraph/nodegraph.cpp @@ -80,6 +80,7 @@ void NodeGraph::setNodeGraphScene(const ScenePtr& scene) } this->_scene = scene; + scene->setSceneRect(-100000, -100000, 200000, 200000); this->setScene(scene.data()); // handle scene's events from within the view @@ -131,25 +132,24 @@ void NodeGraph::scaleDown() void NodeGraph::keyPressEvent(QKeyEvent* event) { if (event->key() == Qt::Key_Delete) { - auto items = this->_scene->selectedItems(); - for (auto item : items) { - if (item->type() == (int)SceneItemType::Node) { - auto node = qgraphicsitem_cast(item); - this->_scene->removeNode(node->sharedFromThis()); - } - else if (item->type() == (int)SceneItemType::Frame) { - auto frame = qgraphicsitem_cast(item); - this->_scene->removeFrame(frame->sharedFromThis()); - } - else if (item->type() == (int)SceneItemType::Comment) { - auto comment = qgraphicsitem_cast(item); - this->_scene->removeComment(comment->sharedFromThis()); - } + QList selectedNodes; + QList selectedFrames; + QList selectedComments; + + for (auto item : this->_scene->selectedItems()) { + if (item->type() == (int)SceneItemType::Node) + selectedNodes.append(qgraphicsitem_cast(item)->sharedFromThis()); + else if (item->type() == (int)SceneItemType::Frame) + selectedFrames.append(qgraphicsitem_cast(item)->sharedFromThis()); + else if (item->type() == (int)SceneItemType::Comment) + selectedComments.append(qgraphicsitem_cast(item)->sharedFromThis()); } + + if (!selectedNodes.isEmpty() || !selectedFrames.isEmpty() || !selectedComments.isEmpty()) + emit deleteRequested(selectedNodes, selectedFrames, selectedComments); } QGraphicsView::keyPressEvent(event); - this->invalidateScene(QRect(-1000, -1000, 1000, 1000)); } @@ -168,21 +168,22 @@ void NodeGraph::keyReleaseEvent(QKeyEvent* event) void NodeGraph::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 NodeGraph::mouseMoveEvent(QMouseEvent* event) { - - if (event->buttons() == Qt::MiddleButton) { - QPointF difference = _clickPos - mapToScene(event->pos()); - setSceneRect(sceneRect().translated(difference.x(), difference.y())); - _clickPos = mapToScene(event->pos()); // Update reference point to maintain coordinate consistency + if (event->buttons() & Qt::MiddleButton) { + QPointF delta = event->pos() - _clickPos; + horizontalScrollBar()->setValue(horizontalScrollBar()->value() - delta.x()); + verticalScrollBar()->setValue(verticalScrollBar()->value() - delta.y()); + _clickPos = event->pos(); + return; } QGraphicsView::mouseMoveEvent(event); } @@ -311,6 +312,13 @@ bool NodeGraph::sceneMousePressEvent(QGraphicsSceneMouseEvent* event) if (mbStates.left) { auto scenePos = event->scenePos(); auto rawPort = this->getPortAtScenePos(scenePos.x(), scenePos.y()); + if (!rawPort) { + // Record node positions for move-tracking (no port drag starting) + _preDragPositions.clear(); + for (auto& node : _scene->nodes) + _preDragPositions[node->id()] = node->getCenter(); + _trackingMove = true; + } if (rawPort) { // auto port = rawPort->node->getPortById(rawPort->id()); // gotta cast to get the non-const version @@ -387,6 +395,22 @@ bool NodeGraph::sceneMouseMoveEvent(QGraphicsSceneMouseEvent* event) activeCon->pos2 = scenePos; } activeCon->updatePathFromPositions(); + + // show socket names on nodes within proximity + for (auto node : _nodesWithSocketNamesShown) + node->setShowSocketNames(false); + _nodesWithSocketNamesShown.clear(); + + for (auto& nodePtr : _scene->nodes) { + auto node = nodePtr.data(); + QPointF center = node->getCenter(); + qreal dx = center.x() - scenePos.x(); + qreal dy = center.y() - scenePos.y(); + if (dx * dx + dy * dy < SOCKET_LABEL_RADIUS * SOCKET_LABEL_RADIUS) { + node->setShowSocketNames(true); + _nodesWithSocketNamesShown.append(node); + } + } } return false; @@ -460,11 +484,34 @@ bool NodeGraph::sceneMouseReleaseEvent(QGraphicsSceneMouseEvent* event) } } + // clear proximity socket labels + for (auto node : _nodesWithSocketNamesShown) + node->setShowSocketNames(false); + _nodesWithSocketNamesShown.clear(); + // remove from scene _scene->removeItem(activeCon.data()); activeCon.clear(); } + // Emit move command if nodes changed position + if (_trackingMove && !activeCon) { + QMap newPositions; + bool moved = false; + for (auto it = _preDragPositions.begin(); it != _preDragPositions.end(); ++it) { + auto node = _scene->nodes.value(it.key()); + if (!node) + continue; + QPointF newPos = node->getCenter(); + newPositions[it.key()] = newPos; + if (newPos != it.value()) + moved = true; + } + if (moved) + emit itemsMoveFinished(_preDragPositions, newPositions); + } + _trackingMove = false; + // important to reset drag! this->setDragMode(QGraphicsView::RubberBandDrag); return false; @@ -506,12 +553,29 @@ void NodeGraph::handleSelectionChange() auto selected = this->_scene->selectedItems(); for (auto item : selected) { if (item->type() == (int)SceneItemType::Node) { + // emit nulls first so downstream handlers clear before setting new selection + emit frameSelectionChanged(FramePtr(nullptr)); + emit commentSelectionChanged(CommentPtr(nullptr)); emit nodeSelectionChanged(((Node*)item)->sharedFromThis()); return; } + if (item->type() == (int)SceneItemType::Frame) { + emit nodeSelectionChanged(NodePtr(nullptr)); + emit commentSelectionChanged(CommentPtr(nullptr)); + emit frameSelectionChanged(((Frame*)item)->sharedFromThis()); + return; + } + if (item->type() == (int)SceneItemType::Comment) { + emit nodeSelectionChanged(NodePtr(nullptr)); + emit frameSelectionChanged(FramePtr(nullptr)); + emit commentSelectionChanged(((Comment*)item)->sharedFromThis()); + return; + } } emit nodeSelectionChanged(NodePtr(nullptr)); + emit frameSelectionChanged(FramePtr(nullptr)); + emit commentSelectionChanged(CommentPtr(nullptr)); } NodeGraph::~NodeGraph() {} diff --git a/src/nodegraph/nodegraph.h b/src/nodegraph/nodegraph.h index b25d062b..c2680b19 100644 --- a/src/nodegraph/nodegraph.h +++ b/src/nodegraph/nodegraph.h @@ -100,6 +100,8 @@ class NodeGraph : public QGraphicsView { void handleSelectionChange(); private: + static constexpr float SOCKET_LABEL_RADIUS = 150.0f; + QPointF _clickPos; ScenePtr _scene; MouseButtonStates mbStates; @@ -107,6 +109,11 @@ class NodeGraph : public QGraphicsView { QList nodes; QList cons; + QList _nodesWithSocketNamesShown; + + // Position tracking for move commands + bool _trackingMove = false; + QMap _preDragPositions; signals: void connectionAdded(ConnectionPtr con); @@ -114,10 +121,23 @@ class NodeGraph : public QGraphicsView { void nodeAdded(NodePtr node); void nodeRemoved(NodePtr node); + // Emitted instead of directly deleting; GraphWidget pushes the undo command + void deleteRequested(QList nodes, + QList frames, + QList comments); + + // Emitted on mouse-release when selected nodes moved; oldPos/newPos keyed by node id + void itemsMoveFinished(QMap oldPositions, + QMap newPositions); + // null nodeptr means no active node selected void nodeSelectionChanged(const NodePtr& node); void nodeDoubleClicked(const NodePtr& node); + // null ptr means no active frame/comment selected + void frameSelectionChanged(const FramePtr& frame); + void commentSelectionChanged(const CommentPtr& comment); + void itemsDeleted(QList nodes, QList cons); }; } // namespace nodegraph \ No newline at end of file diff --git a/src/texturelab/CMakeLists.txt b/src/texturelab/CMakeLists.txt index 0a804e99..bc4ec126 100644 --- a/src/texturelab/CMakeLists.txt +++ b/src/texturelab/CMakeLists.txt @@ -98,13 +98,65 @@ set(LIBRARYV2 ./libraries/v2/warpv2.cpp ) +set(LIBRARYV3 + ./libraries/v3/ambientocclusion.cpp + ./libraries/v3/bevelv2.cpp + ./libraries/v3/curvenode.cpp + ./libraries/v3/curvature.cpp + ./libraries/v3/maskedblur.cpp + ./libraries/v3/rays.cpp + ./libraries/v3/swirl.cpp + ./libraries/v3/floodfillv2.cpp + ./libraries/v3/floodfillv2tocolor.cpp + ./libraries/v3/floodfillv2torandomcolor.cpp + ./libraries/v3/floodfillv2torandomintensity.cpp + ./libraries/v3/floodfillv2tobbox.cpp + ./libraries/v3/floodfillv2togradient.cpp + ./libraries/v3/floodfillv2sampler.cpp + # Phase 1 — Filters / Color + ./libraries/v3/blendv3.cpp + ./libraries/v3/edgedetect.cpp + ./libraries/v3/highpass.cpp + ./libraries/v3/emboss.cpp + ./libraries/v3/vibrance.cpp + ./libraries/v3/colortomask.cpp + ./libraries/v3/setalpha.cpp + ./libraries/v3/toongradient.cpp + ./libraries/v3/autolevels.cpp + # Phase 2 — Generators + ./libraries/v3/bricks2.cpp + ./libraries/v3/directionalscratches.cpp + ./libraries/v3/roughgrain.cpp + ./libraries/v3/voronoifractal.cpp + ./libraries/v3/truchet.cpp + ./libraries/v3/fbmdomainwarp.cpp + ./libraries/v3/perlinnoise.cpp + ./libraries/v3/perlinnoise3d.cpp + ./libraries/v3/cellv3.cpp + ./libraries/v3/linecellv3.cpp + ./libraries/v3/solidcellv3.cpp + # Phase 3 — Multi-pass + ./libraries/v3/blurhq.cpp + ./libraries/v3/distancetransform.cpp + ./libraries/v3/spread.cpp + ./libraries/v3/heightblend.cpp + ./libraries/v3/makeittile.cpp + ./libraries/v3/normalmapv3.cpp +) + set(PROJECT_SOURCES ./main.cpp + ./telemetry.h + ./telemetry.cpp ./mainwindow.cpp ./mainwindow.h + ./clipboard.h + ./clipboard.cpp ./exporter.h ./exporter.cpp ./utils.h + ./curve.h + ./curve.cpp ./models.h ./models.cpp ./project.h @@ -112,9 +164,13 @@ set(PROJECT_SOURCES ./props.h ./props.cpp ./libraries/libv2.h - ./libraries/libv2.h + ./libraries/libv3.h ./libraries/library.h ./libraries/library.cpp + ./libraries/libversion.h + ./libraries/libversion.cpp + ./libraries/libraryversionmigrator.h + ./libraries/libraryversionmigrator.cpp ./widgets/graphwidget.h ./widgets/graphwidget.cpp ./widgets/librarywidget.h @@ -125,18 +181,55 @@ set(PROJECT_SOURCES ./widgets/properties/propertieswidget.cpp ./widgets/properties/propwidgets.h ./widgets/properties/propwidgets.cpp + ./widgets/properties/curvepropwidget.h + ./widgets/properties/curvepropwidget.cpp + ./widgets/properties/accordionwidget.h + ./widgets/properties/accordionwidget.cpp ./widgets/view2dwidget.h ./widgets/view2dwidget.cpp ./widgets/view3dwidget.h ./widgets/view3dwidget.cpp + ./undo/undocommands.h + ./undo/undocommandids.h + ./undo/addnodecommand.h + ./undo/addnodecommand.cpp + ./undo/deleteitemscommand.h + ./undo/deleteitemscommand.cpp + ./undo/addconnectioncommand.h + ./undo/addconnectioncommand.cpp + ./undo/removeconnectioncommand.h + ./undo/removeconnectioncommand.cpp + ./undo/moveitemscommand.h + ./undo/moveitemscommand.cpp + ./undo/propertychangecommand.h + ./undo/propertychangecommand.cpp + ./undo/randomseedchangecommand.h + ./undo/randomseedchangecommand.cpp + ./undo/addframecommand.h + ./undo/addframecommand.cpp + ./undo/addcommentcommand.h + ./undo/addcommentcommand.cpp + ./undo/editframecommand.h + ./undo/editframecommand.cpp + ./undo/editcommentcommand.h + ./undo/editcommentcommand.cpp + ./undo/pastecommand.h + ./undo/pastecommand.cpp + ./undo/texturechannelassigncommand.h + ./undo/texturechannelassigncommand.cpp + ./widgets/aboutdialog.h + ./widgets/aboutdialog.cpp ./widgets/exportdialog.h ./widgets/exportdialog.cpp ./graphics/texturerenderer.h ./graphics/texturerenderer.cpp + ./graphics/noderenderer.h + ./graphics/noderenderer.cpp ./graphics/renderworker.h ./graphics/renderworker.cpp ${LIBRARYV1} ${LIBRARYV2} + ${LIBRARYV3} ) set(PROJECT_RESOURCES @@ -156,14 +249,15 @@ else() endif() # note: openglwidgets is qt6 only -target_link_libraries(texturelab PRIVATE Qt${QT_VERSION_MAJOR}::Widgets - Qt${QT_VERSION_MAJOR}::OpenGL - Qt${QT_VERSION_MAJOR}::OpenGLWidgets +target_link_libraries(texturelab PRIVATE Qt${QT_VERSION_MAJOR}::Widgets + Qt${QT_VERSION_MAJOR}::OpenGL + Qt${QT_VERSION_MAJOR}::OpenGLWidgets OpenGL::GL - qtadvanceddocking-qt6 + qtadvanceddocking-qt6 nodegraph viewer3d colorpicker + sentry ) target_include_directories(texturelab PUBLIC @@ -172,6 +266,47 @@ target_include_directories(texturelab PUBLIC ../viewer3d ../colorpicker ) +# DSN is empty by default for local builds (Sentry init becomes a no-op) +if(NOT DEFINED TEXTURELAB_SENTRY_DSN) + set(TEXTURELAB_SENTRY_DSN "") +endif() + +target_compile_definitions(texturelab PRIVATE + TEXTURELAB_VERSION="0.4.0-beta" + TEXTURELAB_SENTRY_DSN="${TEXTURELAB_SENTRY_DSN}" +) + +# Generate version.h (with git hash) at every build, not just configure time +add_custom_target(texturelab_version + COMMAND ${CMAKE_COMMAND} + -DSOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}" + -DBINARY_DIR="${CMAKE_CURRENT_BINARY_DIR}" + -P "${CMAKE_CURRENT_SOURCE_DIR}/GetGitHash.cmake" + BYPRODUCTS "${CMAKE_CURRENT_BINARY_DIR}/version.h" + COMMENT "Generating version.h" +) +add_dependencies(texturelab texturelab_version) +target_include_directories(texturelab PRIVATE "${CMAKE_CURRENT_BINARY_DIR}") + +# MSVC: deterministically emit a PDB and embed a CodeView record in the exe, in +# every config (incl. Release). Without the CodeView record the shipped exe has +# no debug_id, so Sentry cannot match any PDB to a crash minidump ("Unknown +# function" frames). Attaching to the target is reliable; injecting /Zi + /DEBUG +# via CMAKE_*_FLAGS_RELEASE on the command line was not (VS generator + Qt). +if(MSVC) + target_compile_options(texturelab PRIVATE /Zi) + target_link_options(texturelab PRIVATE /DEBUG /OPT:REF /OPT:ICF /INCREMENTAL:NO) +endif() + +# Copy crashpad_handler next to the executable so handler_path resolves at runtime +if(TARGET crashpad_handler) + add_custom_command(TARGET texturelab POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ + COMMENT "Copying crashpad_handler next to texturelab" + ) +endif() set_target_properties(texturelab PROPERTIES MACOSX_BUNDLE_GUI_IDENTIFIER io.texturelab.app diff --git a/src/texturelab/GetGitHash.cmake b/src/texturelab/GetGitHash.cmake new file mode 100644 index 00000000..bb920925 --- /dev/null +++ b/src/texturelab/GetGitHash.cmake @@ -0,0 +1,10 @@ +execute_process( + COMMAND git -C "${SOURCE_DIR}" rev-parse --short HEAD + OUTPUT_VARIABLE TEXTURELAB_BUILD_HASH + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET +) +if(NOT TEXTURELAB_BUILD_HASH) + set(TEXTURELAB_BUILD_HASH "unknown") +endif() +configure_file("${SOURCE_DIR}/version.h.in" "${BINARY_DIR}/version.h" @ONLY) diff --git a/src/texturelab/assets.qrc b/src/texturelab/assets.qrc index 4d36f814..eed61479 100644 --- a/src/texturelab/assets.qrc +++ b/src/texturelab/assets.qrc @@ -83,6 +83,7 @@ ../../public/assets/nodes/valuenoisefractalsum.png + ../../public/assets/nodes/curve.png ../../public/assets/nodes/frame.png @@ -93,6 +94,7 @@ ../icons/grid.svg ../icons/crosshair.svg ../icons/copy.svg + ../icons/logo.png ../../public/assets/env/cave_wall/cave_wall_1k.hdr @@ -105,6 +107,7 @@ ../../public/assets/env/spruit_sunrise/spruit_sunrise_1k.hdr ../../public/assets/env/studio_small_07/studio_small_07_1k.hdr ../../public/assets/env/wide_street_01_1k.hdr + ../../public/assets/env/sunny_rose_garden_1k.hdr ../../public/assets/examples/Copper.texture diff --git a/src/texturelab/clipboard.cpp b/src/texturelab/clipboard.cpp new file mode 100644 index 00000000..cb55537f --- /dev/null +++ b/src/texturelab/clipboard.cpp @@ -0,0 +1,240 @@ +#include "clipboard.h" +#include "libraries/library.h" +#include "props.h" +#include +#include +#include +#include +#include +#include +#include + +const QString Clipboard::PREFIX = "texturelab-clipboard:"; + +void Clipboard::copyItems(TextureProjectPtr project, + const QList& nodeIds, + const QList& frameIds, + const QList& commentIds) +{ + QSet nodeIdSet(nodeIds.begin(), nodeIds.end()); + + QJsonObject root; + + // Nodes + QJsonArray nodeArray; + for (const auto& id : nodeIds) { + auto node = project->nodes.value(id); + if (!node) + continue; + + QJsonObject obj; + obj["id"] = node->id; + obj["typeName"] = node->typeName; + obj["exportName"] = node->exportName; + obj["randomSeed"] = (double)node->randomSeed; + obj["x"] = node->pos.x(); + obj["y"] = node->pos.y(); + + QJsonObject props; + for (auto key : node->props.keys()) + props[key] = node->props[key]->toJsonValue(); + obj["properties"] = props; + + nodeArray.append(obj); + } + root["nodes"] = nodeArray; + + // Connections — only those fully within the selection + QJsonArray conArray; + for (auto& con : project->connections) { + if (nodeIdSet.contains(con->leftNode->id) && + nodeIdSet.contains(con->rightNode->id)) { + QJsonObject obj; + obj["leftNodeId"] = con->leftNode->id; + obj["rightNodeId"] = con->rightNode->id; + obj["rightNodeInput"] = con->rightNodeInputName; + conArray.append(obj); + } + } + root["connections"] = conArray; + + // Comments + QJsonArray commentArray; + for (const auto& id : commentIds) { + auto comment = project->comments.value(id); + if (!comment) + continue; + QJsonObject obj; + obj["text"] = comment->text; + obj["x"] = comment->pos.x(); + obj["y"] = comment->pos.y(); + commentArray.append(obj); + } + root["comments"] = commentArray; + + // Frames + QJsonArray frameArray; + for (const auto& id : frameIds) { + auto frame = project->frames.value(id); + if (!frame) + continue; + QJsonObject obj; + obj["title"] = frame->text; + obj["color"] = frame->color.name(QColor::HexRgb); + obj["x"] = frame->pos.x(); + obj["y"] = frame->pos.y(); + obj["width"] = frame->size.x(); + obj["height"] = frame->size.y(); + frameArray.append(obj); + } + root["frames"] = frameArray; + + QJsonDocument doc(root); + QApplication::clipboard()->setText(PREFIX + doc.toJson(QJsonDocument::Compact)); +} + +bool Clipboard::hasData() +{ + return QApplication::clipboard()->text().startsWith(PREFIX); +} + +bool Clipboard::pasteItems(TextureProjectPtr project, + QPointF viewCenter, + QList& outNodes, + QList& outConnections, + QList& outComments, + QList& outFrames) +{ + if (!project || !project->library) + return false; + + QString text = QApplication::clipboard()->text(); + if (!text.startsWith(PREFIX)) + return false; + + QJsonParseError err; + auto doc = QJsonDocument::fromJson(text.mid(PREFIX.length()).toUtf8(), &err); + if (err.error || !doc.isObject()) + return false; + + auto root = doc.object(); + + // Compute bounding box of all items in the clipboard to find their center + double minX = std::numeric_limits::max(); + double minY = std::numeric_limits::max(); + double maxX = std::numeric_limits::lowest(); + double maxY = std::numeric_limits::lowest(); + + auto expandBBox = [&](double x, double y) { + minX = std::min(minX, x); minY = std::min(minY, y); + maxX = std::max(maxX, x); maxY = std::max(maxY, y); + }; + + for (auto item : root["nodes"].toArray()) { + auto o = item.toObject(); + expandBBox(o["x"].toDouble(), o["y"].toDouble()); + } + for (auto item : root["comments"].toArray()) { + auto o = item.toObject(); + expandBBox(o["x"].toDouble(), o["y"].toDouble()); + } + for (auto item : root["frames"].toArray()) { + auto o = item.toObject(); + expandBBox(o["x"].toDouble(), o["y"].toDouble()); + expandBBox(o["x"].toDouble() + o["width"].toDouble(), + o["y"].toDouble() + o["height"].toDouble()); + } + + // If nothing in the bbox (empty clipboard somehow), fall back to no shift + double offsetX = 0, offsetY = 0; + if (minX <= maxX && minY <= maxY) { + double bboxCenterX = (minX + maxX) / 2.0; + double bboxCenterY = (minY + maxY) / 2.0; + offsetX = viewCenter.x() - bboxCenterX; + offsetY = viewCenter.y() - bboxCenterY; + } + + // Build old→new node ID map + QMap nodeIdMap; + for (auto item : root["nodes"].toArray()) { + auto oldId = item.toObject()["id"].toString(); + nodeIdMap[oldId] = QUuid::createUuid().toString(QUuid::WithoutBraces); + } + + // Nodes + for (auto item : root["nodes"].toArray()) { + auto obj = item.toObject(); + auto node = project->library->createNode(obj["typeName"].toString()); + if (!node) + continue; + + node->id = nodeIdMap[obj["id"].toString()]; + node->exportName = obj["exportName"].toString(); + node->randomSeed = (long)obj["randomSeed"].toDouble(0); + node->pos = QVector2D((float)(obj["x"].toDouble() + offsetX), + (float)(obj["y"].toDouble() + offsetY)); + + auto propObj = obj["properties"].toObject(); + for (auto key : propObj.keys()) { + auto prop = node->getProp(key); + if (prop) + prop->fromJsonValue(propObj[key]); + } + + outNodes.append(node); + } + + // Connections + for (auto item : root["connections"].toArray()) { + auto obj = item.toObject(); + auto newLeftId = nodeIdMap.value(obj["leftNodeId"].toString()); + auto newRightId = nodeIdMap.value(obj["rightNodeId"].toString()); + if (newLeftId.isEmpty() || newRightId.isEmpty()) + continue; + + TextureNodePtr leftNode, rightNode; + for (auto& n : outNodes) { + if (n->id == newLeftId) + leftNode = n; + if (n->id == newRightId) + rightNode = n; + } + if (!leftNode || !rightNode) + continue; + + auto con = ConnectionPtr(new Connection()); + con->id = QUuid::createUuid().toString(QUuid::WithoutBraces); + con->leftNode = leftNode; + con->rightNode = rightNode; + con->leftNodeOutputName = "output"; + con->rightNodeInputName = obj["rightNodeInput"].toString(); + outConnections.append(con); + } + + // Comments + for (auto item : root["comments"].toArray()) { + auto obj = item.toObject(); + auto comment = CommentPtr(new Comment()); + comment->id = QUuid::createUuid().toString(QUuid::WithoutBraces); + comment->text = obj["text"].toString(); + comment->pos = QVector2D((float)(obj["x"].toDouble() + offsetX), + (float)(obj["y"].toDouble() + offsetY)); + outComments.append(comment); + } + + // Frames + for (auto item : root["frames"].toArray()) { + auto obj = item.toObject(); + auto frame = FramePtr(new Frame()); + frame->id = QUuid::createUuid().toString(QUuid::WithoutBraces); + frame->text = obj["title"].toString(); + frame->color = QColor(obj["color"].toString()); + frame->pos = QVector2D((float)(obj["x"].toDouble() + offsetX), + (float)(obj["y"].toDouble() + offsetY)); + frame->size = QVector2D((float)obj["width"].toDouble(), + (float)obj["height"].toDouble()); + outFrames.append(frame); + } + + return !outNodes.isEmpty() || !outComments.isEmpty() || !outFrames.isEmpty(); +} diff --git a/src/texturelab/clipboard.h b/src/texturelab/clipboard.h new file mode 100644 index 00000000..b5040705 --- /dev/null +++ b/src/texturelab/clipboard.h @@ -0,0 +1,24 @@ +#pragma once + +#include "models.h" +#include + +class Clipboard { +public: + static void copyItems(TextureProjectPtr project, + const QList& nodeIds, + const QList& frameIds, + const QList& commentIds); + + static bool pasteItems(TextureProjectPtr project, + QPointF viewCenter, + QList& outNodes, + QList& outConnections, + QList& outComments, + QList& outFrames); + + static bool hasData(); + +private: + static const QString PREFIX; +}; diff --git a/src/texturelab/curve.cpp b/src/texturelab/curve.cpp new file mode 100644 index 00000000..9df6f5c9 --- /dev/null +++ b/src/texturelab/curve.cpp @@ -0,0 +1,265 @@ +#include "curve.h" + +#include +#include + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static float clamp01(float v) { return qBound(0.0f, v, 1.0f); } + +static float snap(float v) +{ + return qRound(v * 1000.0f) / 1000.0f; +} + +float Curve::cubicBez(float p0, float p1, float p2, float p3, float t) +{ + float mt = 1.0f - t; + return mt*mt*mt*p0 + 3.0f*mt*mt*t*p1 + 3.0f*mt*t*t*p2 + t*t*t*p3; +} + +float Curve::cubicBezD(float p0, float p1, float p2, float p3, float t) +{ + float mt = 1.0f - t; + return 3.0f * (mt*mt*(p1-p0) + 2.0f*mt*t*(p2-p1) + t*t*(p3-p2)); +} + +// --------------------------------------------------------------------------- +// Curve +// --------------------------------------------------------------------------- + +Curve::Curve() +{ + CurvePoint p0; + p0.x = 0.0f; p0.y = 0.0f; + p0.lx = -0.15f; p0.ly = -0.15f; + p0.rx = 0.15f; p0.ry = 0.15f; + p0.smooth = true; + + CurvePoint p1; + p1.x = 1.0f; p1.y = 1.0f; + p1.lx = -0.15f; p1.ly = -0.15f; + p1.rx = 0.15f; p1.ry = 0.15f; + p1.smooth = true; + + points.append(p0); + points.append(p1); +} + +float Curve::derivAt(float x) const +{ + // Find which segment contains x and compute dy/dx numerically + const float eps = 0.001f; + float x1 = qBound(0.0f, x - eps, 1.0f); + float x2 = qBound(0.0f, x + eps, 1.0f); + + // Evaluate y at x1 and x2 by Newton-Raphson on each segment + auto evalY = [&](float tx) -> float { + if (points.size() < 2) return tx; + int seg = points.size() - 2; + for (int i = 0; i < points.size() - 1; i++) { + if (tx <= points[i + 1].x) { seg = i; break; } + } + const CurvePoint& a = points[seg]; + const CurvePoint& b = points[seg + 1]; + float P0x = a.x, P0y = a.y; + float P1x = a.x + a.rx, P1y = a.y + a.ry; + float P2x = b.x + b.lx, P2y = b.y + b.ly; + float P3x = b.x, P3y = b.y; + + float t = qBound(0.0f, (tx - P0x) / qMax(P3x - P0x, 1e-5f), 1.0f); + for (int i = 0; i < 8; i++) { + float bx = cubicBez(P0x, P1x, P2x, P3x, t); + float dbx = cubicBezD(P0x, P1x, P2x, P3x, t); + if (qAbs(dbx) > 1e-6f) t -= (bx - tx) / dbx; + t = qBound(0.0f, t, 1.0f); + } + return cubicBez(P0y, P1y, P2y, P3y, t); + }; + + float dy = evalY(x2) - evalY(x1); + float dx = x2 - x1; + return (dx > 1e-6f) ? dy / dx : 1.0f; +} + +void Curve::addPoint(float x, float y) +{ + if (points.size() >= CURVE_MAX_POINTS) return; + + x = snap(clamp01(x)); + y = snap(clamp01(y)); + + // Find insert position + int insertPos = points.size(); + for (int i = 0; i < points.size(); i++) { + if (points[i].x >= x) { insertPos = i; break; } + } + + CurvePoint pt; + pt.x = x; + pt.y = y; + pt.smooth = true; + + // Auto-tangent: set handles tangent to existing curve at this x + float slope = derivAt(x); + float handleLen = 0.1f; + pt.rx = handleLen; + pt.ry = slope * handleLen; + pt.lx = -handleLen; + pt.ly = -slope * handleLen; + + points.insert(insertPos, pt); + clampHandles(insertPos); +} + +void Curve::removePoint(int index) +{ + if (index <= 0 || index >= points.size() - 1) return; + points.removeAt(index); +} + +void Curve::clampHandles(int index) +{ + if (index < 0 || index >= points.size()) return; + CurvePoint& pt = points[index]; + + // rx must be >= 0 + pt.rx = qMax(pt.rx, 0.0f); + // lx must be <= 0 + pt.lx = qMin(pt.lx, 0.0f); + + // Right handle absolute x must not exceed next anchor x + if (index < points.size() - 1) { + float maxRx = points[index + 1].x - pt.x; + if (pt.rx > maxRx) { + // Scale down proportionally + float scale = (maxRx > 1e-6f) ? maxRx / pt.rx : 0.0f; + pt.rx *= scale; + pt.ry *= scale; + } + } + + // Left handle absolute x must not go below previous anchor x + if (index > 0) { + float minLx = points[index - 1].x - pt.x; // negative + if (pt.lx < minLx) { + float scale = (qAbs(minLx) > 1e-6f) ? minLx / pt.lx : 0.0f; + pt.lx *= scale; + pt.ly *= scale; + } + } +} + +void Curve::moveAnchor(int index, float x, float y) +{ + if (index < 0 || index >= points.size()) return; + CurvePoint& pt = points[index]; + + y = snap(clamp01(y)); + + if (index == 0) { + // x locked + pt.y = y; + } else if (index == points.size() - 1) { + // x locked + pt.y = y; + } else { + float minX = points[index - 1].x + 0.005f; + float maxX = points[index + 1].x - 0.005f; + pt.x = snap(qBound(minX, x, maxX)); + pt.y = y; + } + + clampHandles(index); + if (index > 0) clampHandles(index - 1); + if (index < points.size() - 1) clampHandles(index + 1); +} + +void Curve::moveHandle(int index, bool isLeft, float dx, float dy) +{ + if (index < 0 || index >= points.size()) return; + CurvePoint& pt = points[index]; + + if (isLeft) { + pt.lx = snap(pt.lx + dx); + pt.ly = snap(pt.ly + dy); + if (pt.smooth) { + // Mirror to right handle (same length, opposite direction) + float len = qSqrt(pt.lx*pt.lx + pt.ly*pt.ly); + if (len > 1e-6f) { + float rightLen = qSqrt(pt.rx*pt.rx + pt.ry*pt.ry); + pt.rx = (-pt.lx / len) * rightLen; + pt.ry = (-pt.ly / len) * rightLen; + } + } + } else { + pt.rx = snap(pt.rx + dx); + pt.ry = snap(pt.ry + dy); + if (pt.smooth) { + float len = qSqrt(pt.rx*pt.rx + pt.ry*pt.ry); + if (len > 1e-6f) { + float leftLen = qSqrt(pt.lx*pt.lx + pt.ly*pt.ly); + pt.lx = (-pt.rx / len) * leftLen; + pt.ly = (-pt.ry / len) * leftLen; + } + } + } + + clampHandles(index); +} + +QJsonObject Curve::toJson() const +{ + QJsonArray arr; + for (const auto& pt : points) { + QJsonObject obj; + obj["x"] = (double)pt.x; + obj["y"] = (double)pt.y; + obj["lx"] = (double)pt.lx; + obj["ly"] = (double)pt.ly; + obj["rx"] = (double)pt.rx; + obj["ry"] = (double)pt.ry; + obj["smooth"] = pt.smooth; + arr.append(obj); + } + QJsonObject root; + root["points"] = arr; + return root; +} + +Curve Curve::fromJson(const QJsonObject& obj) +{ + Curve curve; + if (!obj.contains("points") || !obj["points"].isArray()) return curve; + + QJsonArray arr = obj["points"].toArray(); + if (arr.size() < 2) return curve; + + curve.points.clear(); + for (const auto& val : arr) { + auto o = val.toObject(); + CurvePoint pt; + pt.x = clamp01((float)o["x"].toDouble()); + pt.y = clamp01((float)o["y"].toDouble()); + pt.lx = qMin((float)o["lx"].toDouble(), 0.0f); + pt.ly = (float)o["ly"].toDouble(); + pt.rx = qMax((float)o["rx"].toDouble(), 0.0f); + pt.ry = (float)o["ry"].toDouble(); + pt.smooth = o["smooth"].toBool(true); + curve.points.append(pt); + } + + // Sort by x, enforce minimum 2 points + std::sort(curve.points.begin(), curve.points.end(), + [](const CurvePoint& a, const CurvePoint& b) { return a.x < b.x; }); + + if (curve.points.size() < 2) return Curve(); // fallback to identity + + // Lock first/last x + curve.points.first().x = 0.0f; + curve.points.last().x = 1.0f; + + return curve; +} diff --git a/src/texturelab/curve.h b/src/texturelab/curve.h new file mode 100644 index 00000000..9933f108 --- /dev/null +++ b/src/texturelab/curve.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include + +constexpr int CURVE_MAX_POINTS = 32; + +struct CurvePoint { + float x = 0.0f, y = 0.0f; + float lx = -0.15f, ly = 0.0f; // left handle offset (lx <= 0) + float rx = 0.15f, ry = 0.0f; // right handle offset (rx >= 0) + bool smooth = true; // true: handles mirror through anchor +}; + +class Curve { +public: + QVector points; // always sorted by x; minimum 2 points + + // Default: linear identity — (0,0) to (1,1) with horizontal handles + Curve(); + + // Insert sorted by x. Auto-computes handles tangent to the existing + // curve at that x so the visible shape is preserved. + void addPoint(float x, float y); + + // Remove by index. No-op for index 0 or last. + void removePoint(int index); + + // Move anchor. Interior: x clamped between neighbours. + // First/last: x locked at 0/1, only y moves. + // If smooth, handles rotate to stay mirrored. + void moveAnchor(int index, float x, float y); + + // Move one handle by a delta. If smooth=true, opposite handle mirrors. + void moveHandle(int index, bool isLeft, float dx, float dy); + + QJsonObject toJson() const; + static Curve fromJson(const QJsonObject& obj); + +private: + // Evaluate the derivative dy/dx at a given x using finite differences, + // used for auto-tangent on addPoint. + float derivAt(float x) const; + + // Cubic bezier helper: evaluate B(t) for one component + static float cubicBez(float p0, float p1, float p2, float p3, float t); + static float cubicBezD(float p0, float p1, float p2, float p3, float t); + + // Clamp handle offsets to maintain x-monotonicity constraints + void clampHandles(int index); +}; + +Q_DECLARE_METATYPE(Curve) diff --git a/src/texturelab/graphics/noderenderer.cpp b/src/texturelab/graphics/noderenderer.cpp new file mode 100644 index 00000000..4700a1b8 --- /dev/null +++ b/src/texturelab/graphics/noderenderer.cpp @@ -0,0 +1,460 @@ +#include "noderenderer.h" +#include "../props.h" + +#include +#include +#include +#include +#include + +// ============================================================================ +// RenderResourceCache +// ============================================================================ + +void RenderResourceCache::init(QOpenGLFunctions_3_2_Core* glFuncs, GLuint fboId) +{ + gl = glFuncs; + m_fboId = fboId; +} + +void RenderResourceCache::cleanup() +{ + if (!gl) + return; + + for (auto& tex : texturePool) { + if (tex.id != 0) + gl->glDeleteTextures(1, &tex.id); + } + texturePool.clear(); + + qDeleteAll(shaderCache); + shaderCache.clear(); +} + +GLuint RenderResourceCache::acquireTexture(int width, int height) +{ + // Reuse an existing free texture of matching size + for (auto& tex : texturePool) { + if (!tex.inUse && tex.width == width && tex.height == height) { + tex.inUse = true; + return tex.id; + } + } + + // Create new texture + GLuint texId; + gl->glGenTextures(1, &texId); + gl->glBindTexture(GL_TEXTURE_2D, texId); + gl->glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, width, height, 0, GL_RGBA, + GL_FLOAT, nullptr); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + gl->glBindTexture(GL_TEXTURE_2D, 0); + + CachedTexture cached; + cached.id = texId; + cached.width = width; + cached.height = height; + cached.inUse = true; + texturePool.append(cached); + + return texId; +} + +void RenderResourceCache::releaseTexture(GLuint textureId) +{ + for (auto& tex : texturePool) { + if (tex.id == textureId) { + tex.inUse = false; + return; + } + } +} + +void RenderResourceCache::releaseAllTextures() +{ + for (auto& tex : texturePool) { + tex.inUse = false; + } +} + +GLuint RenderResourceCache::getOrCompileShader(const QString& key, + const QString& vertexSource, + const QString& fragmentSource) +{ + if (shaderCache.contains(key)) + return shaderCache[key]->programId(); + + auto program = new QOpenGLShaderProgram(); + + if (!program->addShaderFromSourceCode(QOpenGLShader::Vertex, vertexSource)) { + qDebug() << "NodeRenderer: Vertex shader error [" << key << "]"; + qDebug() << program->log(); + } + + if (!program->addShaderFromSourceCode(QOpenGLShader::Fragment, + fragmentSource)) { + qDebug() << "NodeRenderer: Fragment shader error [" << key << "]"; + qDebug() << program->log(); + } + + // Bind attribute locations matching the worker's VBO layout + program->bindAttributeLocation("a_pos", 0); // VertexUsage::Position + program->bindAttributeLocation("a_color", 1); // VertexUsage::Color + program->bindAttributeLocation("a_texCoord", 2); // VertexUsage::TexCoord0 + + if (!program->link()) { + qDebug() << "NodeRenderer: Shader link error [" << key << "]"; + qDebug() << program->log(); + } + + shaderCache[key] = program; + return program->programId(); +} + +GLuint RenderResourceCache::compileNodeShader( + const QString& key, const QString& processSource, + const QStringList& inputNames, + const QList>& propTypes) +{ + if (shaderCache.contains(key)) + return shaderCache[key]->programId(); + + QString fSource = fragmentPreamble() + randomLib() + gradientLib() + + curveLib() + + generateInputDeclarations(inputNames) + + generatePropDeclarations(propTypes) + "#line 0\n" + + processSource; + + return getOrCompileShader(key, standardVertexSource(), fSource); +} + +void RenderResourceCache::bindFboToTexture(GLuint textureId) +{ + gl->glBindFramebuffer(GL_FRAMEBUFFER, m_fboId); + gl->glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_TEXTURE_2D, textureId, 0); +} + +// ============================================================================ +// Static shader source helpers +// ============================================================================ + +QString RenderResourceCache::standardVertexSource() +{ + return R""""( + #version 150 core + in vec3 a_pos; + in vec2 a_texCoord; + out vec2 v_texCoord; + void main() { + v_texCoord = a_texCoord; + gl_Position = vec4(a_pos, 1); + } + )""""; +} + +QString RenderResourceCache::fragmentPreamble() +{ + return R""""( + #version 150 core + in vec2 v_texCoord; + + #define GRADIENT_MAX_POINTS 32 + #define CURVE_MAX_POINTS 32 + + vec4 process(vec2 uv); + void initRandom(); + + uniform vec2 _textureSize; + + out vec4 fragColor; + + void main() { + initRandom(); + vec4 result = process(v_texCoord); + fragColor = clamp(result, 0.0, 1.0); + } + )""""; +} + +QString RenderResourceCache::randomLib() +{ + // Exact copy of TextureRenderer::createRandomLib() + return R""""( + uniform float _seed; + vec2 _randomStart; + + #define RANDOM_ITERATIONS 1 + + #define HASHSCALE1 443.8975 + #define HASHSCALE3 vec3(443.897, 441.423, 437.195) + #define HASHSCALE4 vec4(443.897, 441.423, 437.195, 444.129) + + float hash12(vec2 p) + { + vec3 p3 = fract(vec3(p.xyx) * HASHSCALE1); + p3 += dot(p3, p3.yzx + 19.19); + return fract((p3.x + p3.y) * p3.z); + } + + vec2 hash22(vec2 p) + { + vec3 p3 = fract(vec3(p.xyx) * HASHSCALE3); + p3 += dot(p3, p3.yzx+19.19); + return fract((p3.xx+p3.yz)*p3.zy); + } + + float _rand(vec2 uv) + { + float a = 0.0; + for (int t = 0; t < RANDOM_ITERATIONS; t++) + { + float v = float(t+1)*.152; + vec2 pos = (uv * v); + a += hash12(pos); + } + return a/float(RANDOM_ITERATIONS); + } + + vec2 _rand2(vec2 uv) + { + vec2 a = vec2(0.0); + for (int t = 0; t < RANDOM_ITERATIONS; t++) + { + float v = float(t+1)*.152; + vec2 pos = (uv * v); + a += hash22(pos); + } + return a/float(RANDOM_ITERATIONS); + } + + float randomFloat(int index) + { + return _rand(_randomStart + vec2(_seed) + vec2(index)); + } + + float randomVec2(int index) + { + return _rand(_randomStart + vec2(_seed) + vec2(index)); + } + + float randomFloat(int index, float start, float end) + { + float r = _rand(_randomStart + vec2(_seed) + vec2(index)); + return start + r*(end-start); + } + + int randomInt(int index, int start, int end) + { + float r = _rand(_randomStart + vec2(_seed) + vec2(index)); + return start + int(r*float(end-start)); + } + + bool randomBool(int index) + { + return _rand(_randomStart + vec2(_seed) + vec2(index)) > 0.5; + } + + void initRandom() + { + _randomStart = v_texCoord; + } + )""""; +} + +QString RenderResourceCache::gradientLib() +{ + // Exact copy of TextureRenderer::createGradientLib() + return R""""( + struct Gradient { + vec3 colors[GRADIENT_MAX_POINTS]; + float positions[GRADIENT_MAX_POINTS]; + int numPoints; + }; + + vec3 sampleGradient(vec3 colors[GRADIENT_MAX_POINTS], + float positions[GRADIENT_MAX_POINTS], + int numPoints, float t) + { + if (numPoints == 0) + return vec3(1,0,0); + + if (numPoints == 1) + return colors[0]; + + if (t <= positions[0]) + return colors[0]; + + int last = numPoints - 1; + if (t >= positions[last]) + return colors[last]; + + for(int i = 0; i < numPoints-1; i++) { + if (positions[i+1] > t) { + vec3 colorA = colors[i]; + vec3 colorB = colors[i+1]; + + float t1 = positions[i]; + float t2 = positions[i+1]; + + float lerpPos = (t - t1)/(t2 - t1); + return mix(colorA, colorB, lerpPos); + } + } + + return vec3(0,0,0); + } + + vec3 sampleGradient(Gradient gradient, float t) + { + return sampleGradient(gradient.colors, gradient.positions, + gradient.numPoints, t); + } + )""""; +} + +QString RenderResourceCache::curveLib() +{ + return R""""( + struct Curve { + int numPoints; + vec2 anchors[CURVE_MAX_POINTS]; + vec2 handleR[CURVE_MAX_POINTS]; + vec2 handleL[CURVE_MAX_POINTS]; + }; + + float _cubicBez(float p0, float p1, float p2, float p3, float t) { + float mt = 1.0 - t; + return mt*mt*mt*p0 + 3.0*mt*mt*t*p1 + 3.0*mt*t*t*p2 + t*t*t*p3; + } + + float _cubicBezD(float p0, float p1, float p2, float p3, float t) { + float mt = 1.0 - t; + return 3.0 * (mt*mt*(p1-p0) + 2.0*mt*t*(p2-p1) + t*t*(p3-p2)); + } + + float evalCurve(Curve c, float x) { + // Find segment: last anchor whose x <= input x + int seg = c.numPoints - 2; + for (int i = 0; i < CURVE_MAX_POINTS - 1; i++) { + if (i >= c.numPoints - 1) break; + if (x <= c.anchors[i + 1].x) { seg = i; break; } + } + + vec2 P0 = c.anchors[seg]; + vec2 P1 = c.handleR[seg]; + vec2 P2 = c.handleL[seg + 1]; + vec2 P3 = c.anchors[seg + 1]; + + // Newton-Raphson: solve Bx(t) = x (8 iterations, constant bound) + float t = clamp((x - P0.x) / max(P3.x - P0.x, 1e-5), 0.0, 1.0); + for (int i = 0; i < 8; i++) { + float bx = _cubicBez(P0.x, P1.x, P2.x, P3.x, t); + float dbx = _cubicBezD(P0.x, P1.x, P2.x, P3.x, t); + t -= (abs(dbx) > 1e-6) ? (bx - x) / dbx : 0.0; + t = clamp(t, 0.0, 1.0); + } + + return _cubicBez(P0.y, P1.y, P2.y, P3.y, t); + } + )""""; +} + +QString RenderResourceCache::generateInputDeclarations( + const QStringList& inputNames) +{ + QString code; + for (const auto& input : inputNames) { + code += "uniform sampler2D " + input + ";\n"; + code += "uniform bool " + input + "_connected;\n"; + } + return code; +} + +QString RenderResourceCache::generatePropDeclarations( + const QList>& propTypes) +{ + QString code; + for (const auto& prop : propTypes) { + const QString& name = prop.first; + auto type = static_cast(prop.second); + + switch (type) { + case PropType::Int: + code += "uniform int prop_" + name + ";\n"; + break; + case PropType::Float: + code += "uniform float prop_" + name + ";\n"; + break; + case PropType::Bool: + code += "uniform bool prop_" + name + ";\n"; + break; + case PropType::Enum: + code += "uniform int prop_" + name + ";\n"; + break; + case PropType::Color: + code += "uniform vec4 prop_" + name + ";\n"; + break; + case PropType::Gradient: + code += "uniform Gradient prop_" + name + ";\n"; + break; + case PropType::Image: + code += "uniform sampler2D prop_" + name + ";\n"; + break; + case PropType::Curve: + code += "uniform Curve prop_" + name + ";\n"; + break; + default: + break; + } + } + return code + "\n"; +} + +// ============================================================================ +// NodeRenderContext +// ============================================================================ + +void NodeRenderContext::useShader(GLuint programId) +{ + gl->glUseProgram(programId); + gl->glViewport(0, 0, textureWidth, textureHeight); + gl->glUniform2f(gl->glGetUniformLocation(programId, "_textureSize"), + (float)textureWidth, (float)textureHeight); + gl->glUniform1f(gl->glGetUniformLocation(programId, "_seed"), randomSeed); +} + +void NodeRenderContext::bindTexture(GLuint programId, + const QString& uniformName, + GLuint textureId, int unit) +{ + gl->glActiveTexture(GL_TEXTURE0 + unit); + gl->glBindTexture(GL_TEXTURE_2D, textureId); + gl->glUniform1i( + gl->glGetUniformLocation(programId, + uniformName.toStdString().c_str()), + unit); +} + +void NodeRenderContext::drawQuad() +{ + vao->bind(); + vbo->bind(); + + // a_pos = attribute 0, a_texCoord = attribute 2 + gl->glEnableVertexAttribArray(0); + gl->glEnableVertexAttribArray(2); + gl->glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), + nullptr); + gl->glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), + reinterpret_cast(3 * sizeof(float))); + + gl->glDrawArrays(GL_TRIANGLES, 0, 6); + + vbo->release(); + vao->release(); +} diff --git a/src/texturelab/graphics/noderenderer.h b/src/texturelab/graphics/noderenderer.h new file mode 100644 index 00000000..81c85fd7 --- /dev/null +++ b/src/texturelab/graphics/noderenderer.h @@ -0,0 +1,126 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +class QOpenGLFunctions_3_2_Core; +class QOpenGLVertexArrayObject; +class QOpenGLBuffer; +class QOpenGLShaderProgram; + +// Input texture binding passed to custom renderers +struct NodeInputBinding { + QString name; + GLuint textureId; +}; + +// Base class for node-specific render data. +// Nodes subclass this to carry parameters to the render thread. +// Must not reference GPU resources — only plain values. +struct NodeRenderData { + virtual ~NodeRenderData() = default; +}; + +// Worker-owned GPU resource pool. +// Manages intermediate textures and compiled shaders. +// All methods must be called on the render thread with GL context active. +class RenderResourceCache { +public: + RenderResourceCache() = default; + + void init(QOpenGLFunctions_3_2_Core* glFuncs, GLuint fboId); + void cleanup(); + + // --- Intermediate textures --- + GLuint acquireTexture(int width, int height); + void releaseTexture(GLuint textureId); + void releaseAllTextures(); + + // --- Shader cache (raw GLSL) --- + GLuint getOrCompileShader(const QString& key, + const QString& vertexSource, + const QString& fragmentSource); + + // --- Shader cache (node-style with standard wrapping) --- + // Wraps processSource with random lib, gradient lib, input/prop declarations. + // propTypes: list of (name, PropType::Value as int) pairs. + GLuint compileNodeShader(const QString& key, + const QString& processSource, + const QStringList& inputNames, + const QList>& propTypes); + + // --- FBO --- + GLuint fboId() const { return m_fboId; } + void bindFboToTexture(GLuint textureId); + + // Standard vertex shader source (shared across all node shaders) + static QString standardVertexSource(); + + // Shader source building blocks — public so other renderers can reuse them + static QString fragmentPreamble(); + static QString randomLib(); + static QString gradientLib(); + static QString curveLib(); + static QString generateInputDeclarations(const QStringList& inputNames); + static QString generatePropDeclarations( + const QList>& propTypes); + +private: + QOpenGLFunctions_3_2_Core* gl = nullptr; + GLuint m_fboId = 0; + + struct CachedTexture { + GLuint id = 0; + int width = 0; + int height = 0; + bool inUse = false; + }; + QList texturePool; + + QMap shaderCache; +}; + +// View into the worker's GL state, passed to renderers at render time. +// Provides helpers for common rendering operations. +struct NodeRenderContext { + QOpenGLFunctions_3_2_Core* gl; + RenderResourceCache* cache; + + GLuint outputTextureId; + int textureWidth; + int textureHeight; + float randomSeed; + + QList inputs; + + QOpenGLVertexArrayObject* vao; + QOpenGLBuffer* vbo; + + // Bind shader, set viewport, set _textureSize and _seed uniforms + void useShader(GLuint programId); + + // Bind a texture to a sampler uniform + void bindTexture(GLuint programId, const QString& uniformName, + GLuint textureId, int unit); + + // Draw a fullscreen quad using the currently bound shader + void drawQuad(); +}; + +// Abstract base for custom node renderers. +// Subclass this to implement multi-pass or custom rendering logic. +// The worker delegates to render() instead of the standard single-pass path. +class NodeTextureRenderer { +public: + virtual ~NodeTextureRenderer() = default; + + // Called on the render thread with full GL context. + // Must write final result to context.outputTextureId. + virtual void render(NodeRenderContext& context, + const NodeRenderData& data) = 0; +}; diff --git a/src/texturelab/graphics/renderworker.cpp b/src/texturelab/graphics/renderworker.cpp index 38e4bc32..ac7bf3eb 100644 --- a/src/texturelab/graphics/renderworker.cpp +++ b/src/texturelab/graphics/renderworker.cpp @@ -1,5 +1,7 @@ #include "renderworker.h" #include "../models.h" +#include "../telemetry.h" +#include "../curve.h" #include "gradient.h" #include "texturerenderer.h" #include @@ -83,6 +85,8 @@ void RenderWorker::setup() { running = true; + Telemetry::breadcrumb("render.setup", "RenderWorker::setup() start"); + // Surface must already be created via initSurface() from main thread if (!surface || !surface->isValid()) { qFatal("Surface not initialized! Call initSurface() from main thread before run()"); @@ -99,6 +103,7 @@ void RenderWorker::setup() qFatal("unable to create surface!"); } + Telemetry::breadcrumb("render.setup", "OpenGL context created, making current"); ctx->makeCurrent(surface); // https://doc-snapshots.qt.io/qt6-dev/gui-changes-qt6.html @@ -205,6 +210,11 @@ void RenderWorker::setup() // gl->glReadBuffer(GL_NONE); gl->glBindFramebuffer(GL_FRAMEBUFFER, 0); + Telemetry::breadcrumb("render.setup", "RenderWorker::setup() complete"); + + // Initialize resource cache for custom node renderers + resourceCache.init(gl, fboId); + #ifdef __linux__ // setup renderdoc if (void* mod = dlopen("librenderdoc.so", RTLD_NOW | RTLD_NOLOAD)) { @@ -219,20 +229,49 @@ void RenderWorker::setup() void RenderWorker::processRenderCommand(const RenderCommand& command) { - // Here you would bind the shader, set up inputs and props, and render to a - // texture. This is a placeholder implementation. + Telemetry::breadcrumb("render", "node: " + command.nodeId.toStdString()); if (rdoc_api) rdoc_api->StartFrameCapture(NULL, NULL); ctx->makeCurrent(surface); - // Handle CPU processing nodes differently - if (command.usesCpuProcessing && command.nodePtr != nullptr) { - // Cast back to TextureNode and call cpuProcess - TextureNode* node = static_cast(command.nodePtr); + // Custom renderer path — node defines its own multi-pass rendering + if (command.renderer) { + NodeRenderContext renderCtx; + renderCtx.gl = gl; + renderCtx.cache = &resourceCache; + renderCtx.outputTextureId = command.textureId; + renderCtx.textureWidth = command.textureWidth; + renderCtx.textureHeight = command.textureHeight; + renderCtx.randomSeed = command.randomSeed; + renderCtx.vao = vao; + renderCtx.vbo = vbo; + + for (const auto& input : command.inputs) { + renderCtx.inputs.append( + NodeInputBinding{input.inputName, input.textureId}); + } + + command.renderer->render(renderCtx, *command.renderData); + resourceCache.releaseAllTextures(); + + gl->glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_TEXTURE_2D, 0, 0); + gl->glBindFramebuffer(GL_FRAMEBUFFER, ctx->defaultFramebufferObject()); + + ctx->doneCurrent(); + + if (rdoc_api) + rdoc_api->EndFrameCapture(NULL, NULL); + + emit nodeRendered(command.nodeId, command.textureId); + return; + } - // Call the CPU processing method with the full command + // CPU processing path + if (command.usesCpuProcessing && command.nodePtr) { + TextureNode* node = command.nodePtr.data(); node->cpuProcess(gl, command); ctx->doneCurrent(); @@ -244,13 +283,23 @@ void RenderWorker::processRenderCommand(const RenderCommand& command) return; } - // Simulate rendering process - GLuint renderedTextureId = - 0; // Replace with actual texture ID after rendering + // Standard single-pass GPU path + renderSinglePass(command); + + gl->glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_TEXTURE_2D, 0, 0); + gl->glBindFramebuffer(GL_FRAMEBUFFER, ctx->defaultFramebufferObject()); + + ctx->doneCurrent(); + + if (rdoc_api) + rdoc_api->EndFrameCapture(NULL, NULL); - // qDebug() << "RenderWorker: Processing render command for node:" - // << command.nodeId; + emit nodeRendered(command.nodeId, command.textureId); +} +void RenderWorker::renderSinglePass(const RenderCommand& command) +{ gl->glBindFramebuffer(GL_FRAMEBUFFER, fboId); gl->glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, command.textureId, 0); @@ -258,9 +307,7 @@ void RenderWorker::processRenderCommand(const RenderCommand& command) GLenum status = gl->glCheckFramebufferStatus(GL_FRAMEBUFFER); if (status != GL_FRAMEBUFFER_COMPLETE) { qFatal("FRAMEBUFFER IS NOT COMPLETE!"); - // qWarning("%s Framebuffer is not complete!", command.nodeId); } - // fbo->bind(); gl->glViewport(0, 0, command.textureWidth, command.textureHeight); @@ -268,8 +315,6 @@ void RenderWorker::processRenderCommand(const RenderCommand& command) gl->glClearDepth(0); gl->glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); - // qDebug() << "RenderWorker: Cleared framebuffer for node:" << - // command.nodeId; vao->bind(); if (command.shaderLinked) { @@ -281,7 +326,6 @@ void RenderWorker::processRenderCommand(const RenderCommand& command) gl->glActiveTexture(GL_TEXTURE0 + texIndex); gl->glBindTexture(GL_TEXTURE_2D, 0); - // gl->glUniform1i(node->shader->uniformLocation(input), 0); gl->glUniform1i( gl->glGetUniformLocation(command.shaderId, input.inputName.toStdString().c_str()), @@ -299,12 +343,9 @@ void RenderWorker::processRenderCommand(const RenderCommand& command) texIndex = 0; for (auto nodeInput : command.inputs) { gl->glActiveTexture(GL_TEXTURE0 + texIndex); - // if (!nodeInput.node->texture->bind()) - // qFatal("could not bind texture"); gl->glBindTexture(GL_TEXTURE_2D, nodeInput.textureId); auto name = nodeInput.inputName; - // gl->glUniform1i(node->shader->uniformLocation(input), 0); gl->glUniform1i(gl->glGetUniformLocation( command.shaderId, name.toStdString().c_str()), texIndex); @@ -312,23 +353,15 @@ void RenderWorker::processRenderCommand(const RenderCommand& command) gl->glUniform1i(gl->glGetUniformLocation(command.shaderId, connectedName.c_str()), 1); - // shader->setUniformValue(name.toStdString().c_str(), texIndex); - // shader->setUniformValue((name + - // "_connected").toStdString().c_str(), - // 1); texIndex++; } // pass seed - // shader->setUniformValue("_seed", (GLfloat)(command.randomSeed)); gl->glUniform1f(gl->glGetUniformLocation(command.shaderId, "_seed"), (GLfloat)(command.randomSeed)); // texture size - // shader->setUniformValue( - // "_textureSize", - // QVector2D(command.textureWidth, command.textureHeight)); gl->glUniform2f( gl->glGetUniformLocation(command.shaderId, "_textureSize"), (GLfloat)(command.textureWidth), (GLfloat)(command.textureHeight)); @@ -337,7 +370,6 @@ void RenderWorker::processRenderCommand(const RenderCommand& command) for (auto prop : command.props) { auto propCString = ("prop_" + prop.propName.toStdString()); auto propName = propCString.c_str(); - // qDebug() << "glsl prop: " << propName; switch (prop.propType) { case PropType::Int: { @@ -375,18 +407,15 @@ void RenderWorker::processRenderCommand(const RenderCommand& command) auto gradientVal = prop.value.value(); auto numPoints = gradientVal.points.size(); - // Set number of gradient points gl->glUniform1i( gl->glGetUniformLocation( command.shaderId, (propCString + ".numPoints").c_str()), numPoints); - // Pass each gradient point (color and position) for (int i = 0; i < numPoints; i++) { const auto& point = gradientVal.points[i]; const auto& color = point.color; - // Set color for this point std::string colorPath = propCString + ".colors[" + std::to_string(i) + "]"; gl->glUniform3f(gl->glGetUniformLocation(command.shaderId, @@ -394,7 +423,6 @@ void RenderWorker::processRenderCommand(const RenderCommand& command) color.redF(), color.greenF(), color.blueF()); - // Set position for this point std::string posPath = propCString + ".positions[" + std::to_string(i) + "]"; gl->glUniform1f(gl->glGetUniformLocation(command.shaderId, @@ -403,7 +431,6 @@ void RenderWorker::processRenderCommand(const RenderCommand& command) } } break; case PropType::Image: { - // Use pre-uploaded texture ID from main thread if (prop.textureId != 0) { gl->glActiveTexture(GL_TEXTURE0 + texIndex); gl->glBindTexture(GL_TEXTURE_2D, prop.textureId); @@ -413,16 +440,43 @@ void RenderWorker::processRenderCommand(const RenderCommand& command) texIndex++; } else { - // No texture provided, bind a default texture (e.g., white) gl->glActiveTexture(GL_TEXTURE0 + texIndex); - gl->glBindTexture(GL_TEXTURE_2D, 0); // Bind to 0 or a - // default texture + gl->glBindTexture(GL_TEXTURE_2D, 0); gl->glUniform1i( gl->glGetUniformLocation(command.shaderId, propName), texIndex); texIndex++; } } break; + case PropType::Curve: { + auto curve = prop.value.value(); + int nPoints = qMin((int)curve.points.size(), CURVE_MAX_POINTS); + + gl->glUniform1i( + gl->glGetUniformLocation(command.shaderId, + (propCString + ".numPoints").c_str()), + nPoints); + + for (int i = 0; i < nPoints; i++) { + const auto& pt = curve.points[i]; + std::string idx = "[" + std::to_string(i) + "]"; + + gl->glUniform2f( + gl->glGetUniformLocation(command.shaderId, + (propCString + ".anchors" + idx).c_str()), + pt.x, pt.y); + + gl->glUniform2f( + gl->glGetUniformLocation(command.shaderId, + (propCString + ".handleR" + idx).c_str()), + pt.x + pt.rx, pt.y + pt.ry); + + gl->glUniform2f( + gl->glGetUniformLocation(command.shaderId, + (propCString + ".handleL" + idx).c_str()), + pt.x + pt.lx, pt.y + pt.ly); + } + } break; } } @@ -442,27 +496,6 @@ void RenderWorker::processRenderCommand(const RenderCommand& command) } vao->release(); - - // gl->glBindFramebuffer(GL_FRAMEBUFFER, - // ctx->defaultFramebufferObject()); - - // grab pixels to pixmap - // auto img = node->texture->toImage(); - // img.save(node->id + ".png"); - - // gl->glBindFramebuffer(GL_FRAMEBUFFER, 0); - // fbo->release(); - - gl->glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, - GL_TEXTURE_2D, 0, 0); - gl->glBindFramebuffer(GL_FRAMEBUFFER, ctx->defaultFramebufferObject()); - - ctx->doneCurrent(); - - if (rdoc_api) - rdoc_api->EndFrameCapture(NULL, NULL); - // Emit signal that node has been rendered - emit nodeRendered(command.nodeId, command.textureId); } void RenderWorker::kill() { running = false; } diff --git a/src/texturelab/graphics/renderworker.h b/src/texturelab/graphics/renderworker.h index 94cd957c..ec96b636 100644 --- a/src/texturelab/graphics/renderworker.h +++ b/src/texturelab/graphics/renderworker.h @@ -1,12 +1,15 @@ #pragma once #include "../props.h" +#include "noderenderer.h" #include #include #include #include #include +#include #include +#include class QOffscreenSurface; class QOpenGLContext; @@ -17,6 +20,9 @@ class QOpenGLShader; class QOpenGLShaderProgram; class QOpenGLFramebufferObject; +class TextureNode; +typedef QSharedPointer TextureNodePtr; + struct RenderNodeInput { QString nodeId; QString inputName; @@ -45,7 +51,9 @@ struct RenderCommand { // CPU processing support bool usesCpuProcessing = false; - void* nodePtr = nullptr; // TextureNode* pointer for CPU processing + // Shared (not raw) so the node stays alive for the lifetime of this + // command even if it's removed from the project while queued/in-flight. + TextureNodePtr nodePtr; // all expected inputs need to be cleared int totalInputs; @@ -53,6 +61,10 @@ struct RenderCommand { // props QList props; + + // Custom rendering support (null = standard single-pass) + std::shared_ptr renderer; + std::shared_ptr renderData; }; class RenderWorker : public QObject { @@ -70,10 +82,14 @@ class RenderWorker : public QObject { // use custom dbo that gets shared across render textures GLuint fboId; + RenderResourceCache resourceCache; + QMutex mutex; std::atomic running; QQueue renderQueue; + void renderSinglePass(const RenderCommand& command); + public: RenderWorker(); diff --git a/src/texturelab/graphics/texturerenderer.cpp b/src/texturelab/graphics/texturerenderer.cpp index 63a7d8e7..6da3865f 100644 --- a/src/texturelab/graphics/texturerenderer.cpp +++ b/src/texturelab/graphics/texturerenderer.cpp @@ -1,4 +1,5 @@ #include "texturerenderer.h" +#include "noderenderer.h" #include "renderworker.h" // #include "../models.h" @@ -342,22 +343,22 @@ void TextureRenderer::update() } // if the resolution has changed, resize texture - if (project->textureWidth != node->textureWidth || - project->textureHeight != node->textureHeight) { - // resize - // resizeNodeTexture(node); - // node->textureWidth = project->textureWidth; - // node->textureHeight = project->textureHeight; - // node->texture = new QOpenGLFramebufferObject(node->textureWidth, - // node->textureHeight); - + // Deferred while a render is in flight: the texture/FBO being + // replaced here may be captured as an input GLuint in the command + // currently queued/executing on the worker thread. Once that + // command completes, nodeRendered() re-invokes update(), which will + // pick this resize back up. + if (!renderInFlight && + (project->textureWidth != node->textureWidth || + project->textureHeight != node->textureHeight)) { this->createNodeTexture(node); // clear pixmap and emit thumbnail changed? } } - this->queueNextNodeToRender(); + if (!renderInFlight) + this->queueNextNodeToRender(); } void TextureRenderer::updateOld() @@ -430,6 +431,14 @@ void TextureRenderer::createNodeTexture(const TextureNodePtr& node) { ctx->makeCurrent(surface); + // Safe to free now: callers only reach here when !renderInFlight, so no + // queued/executing RenderCommand can be holding this texture's GLuint + // as an input. + if (node->texture) { + delete node->texture; + node->texture = nullptr; + } + // create fbo QOpenGLFramebufferObjectFormat fboFormat; fboFormat.setInternalTextureFormat(GL_RGBA32F); @@ -448,6 +457,10 @@ void TextureRenderer::createNodeTexture(const TextureNodePtr& node) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); gl->glBindTexture(GL_TEXTURE_2D, 0); + // This texture ID is shared into the render worker's context on another + // thread; flush so its creation is visible there before it's used. + gl->glFlush(); + ctx->doneCurrent(); } @@ -596,11 +609,18 @@ void TextureRenderer::initRenderWorker() void TextureRenderer::nodeRendered(const QString& nodeId, GLuint texId) { qDebug() << "TextureRenderer: Node rendered:" << nodeId; - // queue up next node to render + renderInFlight = false; emit thumbnailGenerated(nodeId, texId, QPixmap()); - - this->queueNextNodeToRender(); - // QTimer::singleShot(0, this, &TextureRenderer::queueNextNodeToRender); + // update() re-checks pending resizes deferred while a render was in + // flight, then queues the next node. + this->update(); + if (project) { + int total = project->nodes.size(); + int clean = 0; + for (const auto& n : project->nodes) + if (!n->isDirty) clean++; + emit renderProgress(clean, total); + } } void TextureRenderer::queueNextNodeToRender() @@ -617,9 +637,18 @@ void TextureRenderer::queueNextNodeToRender() cmd.shaderLinked = nextNode->shader->isLinked(); cmd.randomSeed = project->randomSeed + nextNode->randomSeed; + // Custom renderer support + cmd.renderer = nextNode->createRenderer(); + if (cmd.renderer) { + cmd.renderData = nextNode->createRenderData(); + } + // CPU processing support cmd.usesCpuProcessing = nextNode->usesCpuProcessing; - cmd.nodePtr = nextNode.data(); // Store raw pointer for CPU processing + // Keep the node alive for the lifetime of the command (it may still + // be queued or mid-render on the worker thread if the node is + // removed from the project in the meantime). + cmd.nodePtr = nextNode; cmd.totalInputs = nextNode->inputs.size(); @@ -650,6 +679,10 @@ void TextureRenderer::queueNextNodeToRender() rnp.textureId = imageProp->getTextureId(); + // Shared into the render worker's context on another + // thread; flush so the upload is visible there. + gl->glFlush(); + ctx->doneCurrent(); } } @@ -665,6 +698,7 @@ void TextureRenderer::queueNextNodeToRender() // pass to render worker to process renderWorker->setRenderQueue(queue); + renderInFlight = true; // mark node as clean before rendering to avoid double-queuing nextNode->isDirty = false; @@ -724,50 +758,23 @@ TextureRenderer::buildShaderForNode(const TextureNodePtr& node) QOpenGLShader* fshader = new QOpenGLShader(QOpenGLShader::Fragment); auto program = new QOpenGLShaderProgram; - QString vSource = R""""( - #version 150 core + // Build input/prop declaration lists for RenderResourceCache helpers + QStringList inputNames = node->inputs; - //precision highp float; + QList> propTypes; + for (auto prop : node->props) + propTypes.append({prop->name, (int)prop->type}); - in vec3 a_pos; - in vec2 a_texCoord; + QString fSource = RenderResourceCache::fragmentPreamble() + + RenderResourceCache::randomLib() + + RenderResourceCache::gradientLib() + + RenderResourceCache::curveLib() + + RenderResourceCache::generateInputDeclarations(inputNames) + + RenderResourceCache::generatePropDeclarations(propTypes) + + "#line 0\n" + + node->shaderSource; - out vec2 v_texCoord; - - void main() - { - v_texCoord = a_texCoord; - gl_Position = vec4(a_pos,1); - } - )""""; - - QString fSource = R""""( - #version 150 core - //precision highp float; - in vec2 v_texCoord; - - #define GRADIENT_MAX_POINTS 32 - - vec4 process(vec2 uv); - void initRandom(); - - uniform vec2 _textureSize; - - out vec4 fragColor; - - void main() { - initRandom(); - vec4 result = process(v_texCoord); - fragColor = clamp(result, 0.0, 1.0); - } - - )""""; - - fSource = fSource + this->createRandomLib() + this->createGradientLib() + - this->createCodeForInputs(node) + this->createCodeForProps(node) + - "#line 0\n" + node->shaderSource; - - if (!vshader->compileSourceCode(vSource)) { + if (!vshader->compileSourceCode(RenderResourceCache::standardVertexSource())) { qDebug() << "VERTEX SHADER ERROR"; qDebug() << vshader->log(); } @@ -777,10 +784,7 @@ TextureRenderer::buildShaderForNode(const TextureNodePtr& node) qDebug() << fshader->log(); } - // qDebug() << fSource; - program->removeAllShaders(); - program->addShader(vshader); program->addShader(fshader); @@ -793,199 +797,11 @@ TextureRenderer::buildShaderForNode(const TextureNodePtr& node) qDebug() << program->log(); } + // Shared into the render worker's context on another thread; flush so + // the link is visible there before glUseProgram() is called on it. + gl->glFlush(); + ctx->doneCurrent(); return program; -} - -QString TextureRenderer::createRandomLib() -{ - return R""""( - // this offsets the random start (should be a uniform) - uniform float _seed; - // this is the starting number for the rng - // (should be set from the uv coordinates so it's unique per pixel) - vec2 _randomStart; - - // gives a much better distribution at 1 - #define RANDOM_ITERATIONS 1 - - #define HASHSCALE1 443.8975 - #define HASHSCALE3 vec3(443.897, 441.423, 437.195) - #define HASHSCALE4 vec4(443.897, 441.423, 437.195, 444.129) - - // 1 out, 2 in... - float hash12(vec2 p) - { - vec3 p3 = fract(vec3(p.xyx) * HASHSCALE1); - p3 += dot(p3, p3.yzx + 19.19); - return fract((p3.x + p3.y) * p3.z); - } - - /// 2 out, 2 in... - vec2 hash22(vec2 p) - { - vec3 p3 = fract(vec3(p.xyx) * HASHSCALE3); - p3 += dot(p3, p3.yzx+19.19); - return fract((p3.xx+p3.yz)*p3.zy); - - } - - - float _rand(vec2 uv) - { - float a = 0.0; - for (int t = 0; t < RANDOM_ITERATIONS; t++) - { - float v = float(t+1)*.152; - // 0.005 is a good value - vec2 pos = (uv * v); - a += hash12(pos); - } - - return a/float(RANDOM_ITERATIONS); - } - - vec2 _rand2(vec2 uv) - { - vec2 a = vec2(0.0); - for (int t = 0; t < RANDOM_ITERATIONS; t++) - { - float v = float(t+1)*.152; - // 0.005 is a good value - vec2 pos = (uv * v); - a += hash22(pos); - } - - return a/float(RANDOM_ITERATIONS); - } - - float randomFloat(int index) - { - return _rand(_randomStart + vec2(_seed) + vec2(index)); - } - - float randomVec2(int index) - { - return _rand(_randomStart + vec2(_seed) + vec2(index)); - } - - float randomFloat(int index, float start, float end) - { - float r = _rand(_randomStart + vec2(_seed) + vec2(index)); - return start + r*(end-start); - } - - int randomInt(int index, int start, int end) - { - float r = _rand(_randomStart + vec2(_seed) + vec2(index)); - return start + int(r*float(end-start)); - } - - bool randomBool(int index) - { - return _rand(_randomStart + vec2(_seed) + vec2(index)) > 0.5; - } - - void initRandom() - { - _randomStart = v_texCoord; - } - )""""; -} - -QString TextureRenderer::createGradientLib() -{ - return R""""( - struct Gradient { - vec3 colors[GRADIENT_MAX_POINTS]; - float positions[GRADIENT_MAX_POINTS]; - int numPoints; - }; - - // assumes points are sorted - vec3 sampleGradient(vec3 colors[GRADIENT_MAX_POINTS], float positions[GRADIENT_MAX_POINTS], int numPoints, float t) - { - if (numPoints == 0) - return vec3(1,0,0); - - if (numPoints == 1) - return colors[0]; - - // here at least two points are available - if (t <= positions[0]) - return colors[0]; - - int last = numPoints - 1; - if (t >= positions[last]) - return colors[last]; - - // find two points in-between and lerp - - for(int i = 0; i < numPoints-1;i++) { - if (positions[i+1] > t) { - vec3 colorA = colors[i]; - vec3 colorB = colors[i+1]; - - float t1 = positions[i]; - float t2 = positions[i+1]; - - float lerpPos = (t - t1)/(t2 - t1); - return mix(colorA, colorB, lerpPos); - - } - - } - - return vec3(0,0,0); - } - - vec3 sampleGradient(Gradient gradient, float t) - { - return sampleGradient(gradient.colors, gradient.positions, gradient.numPoints, t); - } - )""""; -} -QString TextureRenderer::createCodeForInputs(const TextureNodePtr& node) -{ - QString code = ""; - for (auto input : node->inputs) { - code += "uniform sampler2D " + input + ";\n"; - code += "uniform bool " + input + "_connected;\n"; - } - - return code; -} - -QString TextureRenderer::createCodeForProps(const TextureNodePtr& node) -{ - QString code = ""; - - for (auto prop : node->props) { - switch (prop->type) { - case PropType::Int: - code += "uniform int prop_" + prop->name + ";\n"; - break; - case PropType::Float: - code += "uniform float prop_" + prop->name + ";\n"; - break; - case PropType::Bool: - code += "uniform bool prop_" + prop->name + ";\n"; - break; - case PropType::Enum: - code += "uniform int prop_" + prop->name + ";\n"; - break; - case PropType::Color: - code += "uniform vec4 prop_" + prop->name + ";\n"; - break; - case PropType::Gradient: - code += "uniform Gradient prop_" + prop->name + ";\n"; - break; - case PropType::Image: - code += "uniform sampler2D prop_" + prop->name + ";\n"; - break; - } - } - - return code + "\n"; } \ No newline at end of file diff --git a/src/texturelab/graphics/texturerenderer.h b/src/texturelab/graphics/texturerenderer.h index e30f0181..79e9b0dd 100644 --- a/src/texturelab/graphics/texturerenderer.h +++ b/src/texturelab/graphics/texturerenderer.h @@ -39,6 +39,12 @@ class TextureRenderer : public QObject { QThread* renderThread; RenderWorker* renderWorker; + // True from the moment a RenderCommand is handed to the worker until its + // nodeRendered() callback fires. GUI-thread only. While true, node + // textures/FBOs must not be resized/recreated: their GLuints may be + // captured as inputs in the in-flight command. + bool renderInFlight = false; + public: TextureRenderer(); ~TextureRenderer(); @@ -64,14 +70,11 @@ class TextureRenderer : public QObject { QVector getNodeInputs(const TextureNodePtr& node); TextureNodePtr getNextUpdatableNode() const; QOpenGLShaderProgram* buildShaderForNode(const TextureNodePtr& node); - QString createRandomLib(); - QString createGradientLib(); - QString createCodeForInputs(const TextureNodePtr& node); - QString createCodeForProps(const TextureNodePtr& node); signals: void thumbnailGenerated(const QString& nodeId, GLuint texId, const QPixmap& pixmap); + void renderProgress(int clean, int total); }; // note: there's no specified fbo limit diff --git a/src/texturelab/libraries/library.cpp b/src/texturelab/libraries/library.cpp index 863dc04a..b07bdfd9 100644 --- a/src/texturelab/libraries/library.cpp +++ b/src/texturelab/libraries/library.cpp @@ -2,6 +2,7 @@ #include "../models.h" #include "libv1.h" #include "libv2.h" +#include "libv3.h" #include @@ -11,6 +12,7 @@ TextureNodePtr Library::createNode(QString name) auto& item = items[name]; if (item.name == name) { auto node = item.factoryFunction(); + node->typeName = name; // todo: put this in the appropriate place node->init(); @@ -181,5 +183,111 @@ Library* createLibraryV2() ":nodes/valuenoisefractalsum.png"); lib->addNode("warp", "Warp", ":nodes/warp.png"); + return lib; +} + +Library* createLibraryV3() +{ + auto lib = createLibraryV2(); + + // Remove V1/V2 nodes that are superseded by V3 equivalents + lib->items.remove("floodfill"); + lib->items.remove("floodfillsampler"); + lib->items.remove("floodfilltobbox"); + lib->items.remove("floodfilltocolor"); + lib->items.remove("floodfilltogradient"); + lib->items.remove("floodfilltorandomcolor"); + lib->items.remove("floodfilltorandomintensity"); + lib->items.remove("bevel"); + lib->items.remove("perlin3d"); + lib->items.remove("blend"); + lib->items.remove("cell"); + lib->items.remove("linecell"); + lib->items.remove("solidcell"); + + // V3 NODES + lib->addNode("bevelv2", "Bevel V2", ":nodes/bevel.png"); + lib->addNode("ambientocclusion", "Ambient Occlusion", + ":nodes/bevel.png"); + lib->addNode("curvature", "Curvature", ":nodes/bevel.png"); + lib->addNode("maskedblur", "Masked Blur", + ":nodes/blurv2.png"); + lib->addNode("rays", "Rays", ":nodes/bevel.png"); + lib->addNode("swirl", "Swirl", ":nodes/bevel.png"); + lib->addNode("floodfillv2", "Flood Fill V2", + ":nodes/floodfill.png"); + lib->addNode("floodfillv2tocolor", "FF To Color V2", + ":nodes/floodfilltocolor.png"); + lib->addNode( + "floodfillv2torandomcolor", "FF To Random Color V2", + ":nodes/floodfilltorandomcolor.png"); + lib->addNode( + "floodfillv2torandomintensity", "FF To Random Intensity V2", + ":nodes/floodfilltorandomintensity.png"); + lib->addNode("floodfillv2tobbox", "FF To BBox V2", + ":nodes/floodfilltobbox.png"); + lib->addNode("floodfillv2togradient", + "FF To Gradient V2", + ":nodes/floodfilltogradient.png"); + lib->addNode("floodfillv2sampler", "FF Sampler V2", + ":nodes/floodfillsampler.png"); + + lib->addNode("curve", "Curve", ":nodes/curve.png"); + + // ----------------------------------------------------------------------- + // Phase 1 — Filters / Color + // ----------------------------------------------------------------------- + lib->addNode("blend", "Blend", ":nodes/blend.png"); + lib->addNode("edgedetect", "Edge Detect", + ":nodes/bevel.png"); + lib->addNode("highpass", "Highpass", ":nodes/blurv2.png"); + lib->addNode("emboss", "Emboss", ":nodes/normalmap.png"); + lib->addNode("vibrance", "Vibrance", ":nodes/hsl.png"); + lib->addNode("colortomask", "Color To Mask", + ":nodes/extractchannel.png"); + lib->addNode("setalpha", "Set Alpha", ":nodes/rgbamerge.png"); + lib->addNode("toongradient", "Toon Gradient", + ":nodes/gradientmap.png"); + lib->addNode("autolevels", "Auto Levels", + ":nodes/histogramscan.png"); + + // ----------------------------------------------------------------------- + // Phase 2 — Generators + // ----------------------------------------------------------------------- + lib->addNode("bricks2", "Bricks 2", + ":nodes/brickgenerator.png"); + lib->addNode( + "directionalscratches", "Directional Scratches", ":nodes/cell.png"); + lib->addNode("roughgrain", "Rough Grain", + ":nodes/cell.png"); + lib->addNode("voronoifractal", "Voronoi Fractal", + ":nodes/cell.png"); + lib->addNode("truchet", "Truchet", ":nodes/hexagon.png"); + lib->addNode("fbmdomainwarp", "FBM Domain Warp", + ":nodes/fractalnoise.png"); + lib->addNode("perlinnoise", "Perlin Noise", + ":nodes/fractalnoise.png"); + lib->addNode("perlinnoise3d", "Perlin Noise 3D", + ":nodes/fractalnoise.png"); + lib->addNode("cell", "Cell V3", ":nodes/cell.png"); + lib->addNode("linecell", "Line Cell V3", + ":nodes/linecell.png"); + lib->addNode("solidcell", "Solid Cell V3", + ":nodes/solidcell.png"); + + // ----------------------------------------------------------------------- + // Phase 3 — Multi-pass + // ----------------------------------------------------------------------- + lib->addNode("blurhq", "Blur HQ", ":nodes/blurv2.png"); + lib->addNode( + "distancetransform", "Distance Transform", ":nodes/bevel.png"); + lib->addNode("spread", "Spread", ":nodes/bevel.png"); + lib->addNode("heightblend", "Height Blend", + ":nodes/blend.png"); + // lib->addNode("makeittile", "Make It Tile", + // ":nodes/tile.png"); + // lib->addNode("normalmapv3", "Normal Map V3", + // ":nodes/normalmap.png"); + return lib; } \ No newline at end of file diff --git a/src/texturelab/libraries/library.h b/src/texturelab/libraries/library.h index 9e159147..aedefa65 100644 --- a/src/texturelab/libraries/library.h +++ b/src/texturelab/libraries/library.h @@ -41,6 +41,7 @@ class Library }; Library *createLibraryV2(); +Library *createLibraryV3(); class LibraryV1 : public Library { diff --git a/src/texturelab/libraries/libraryversionmigrator.cpp b/src/texturelab/libraries/libraryversionmigrator.cpp new file mode 100644 index 00000000..e9929786 --- /dev/null +++ b/src/texturelab/libraries/libraryversionmigrator.cpp @@ -0,0 +1,158 @@ +#include "libraryversionmigrator.h" + +namespace { + +// v2 -> v3. The only non-empty step table that exists today. Every other +// v2 typeName resolves to the same class in v3 and needs no entry here — +// only the typeNames that were actually removed, renamed, or silently +// swapped for a different implementation are listed. +// +// No propertyKeyRenames are needed for any of these: the existing +// node-load loop (Project::loadTextureFromJson) already applies JSON +// properties by key and skips anything the target node doesn't have, +// which reproduces "copy matching props, drop the rest, default the new +// ones" without any extra code here. +const QVector& v2ToV3Table() +{ + static const QVector table = { + {"floodfill", "floodfillv2", {}}, + {"floodfillsampler", "floodfillv2sampler", {}}, + {"floodfilltobbox", "floodfillv2tobbox", {}}, + {"floodfilltocolor", "floodfillv2tocolor", {}}, + {"floodfilltogradient", "floodfillv2togradient", {}}, + {"floodfilltorandomcolor", "floodfillv2torandomcolor", {}}, + {"floodfilltorandomintensity", "floodfillv2torandomintensity", {}}, + // bevelv2 added "invert" and "scaleInvariant" toggles that don't + // exist on the old bevel node. Their library defaults (false, + // true) are tuned for new nodes; a migrated node needs the + // opposite of both to reproduce the old node's look: old bevel's + // output polarity was flipped relative to bevelv2's, and old + // bevel always worked in raw pixel distance (not normalized + // against texture resolution). + {"bevel", "bevelv2", {}, QJsonObject{ + {"invert", true}, + {"scaleInvariant", false}, + }}, + {"perlin3d", "perlinnoise3d", {}}, + {"blend", "blend", {}}, // same name, new class (BlendV3Node) + {"cell", "cell", {}}, // same name, new class (CellV3Node) + {"linecell", "linecell", {}}, // same name, new class (LineCellV3Node) + {"solidcell", "solidcell", {}}, // same name, new class (SolidCellV3Node) + }; + return table; +} + +} // namespace + +QVector migrationStepTable(LibVersion from) +{ + switch (from) { + case LibVersion::V1: + return {}; // v1 -> v2 is purely additive, nothing to migrate + case LibVersion::V2: + return v2ToV3Table(); + case LibVersion::V3: + return {}; // current version: no further step + } + return {}; +} + +LibraryVersionMigrator::LibraryVersionMigrator(QJsonObject projectJson) + : _json(std::move(projectJson)), _target(currentLibVersion()) +{ + auto versionStr = _json["libraryVersion"].toString(); + if (!versionStr.isEmpty()) + _source = libVersionFromString(versionStr); + else + _source = looksLegacy(_json) ? LibVersion::V2 : currentLibVersion(); +} + +bool LibraryVersionMigrator::needsMigration() const +{ + return _source != _target; +} + +QList LibraryVersionMigrator::versionsCrossed() const +{ + QList chain; + for (LibVersion v = _source; v != _target; v = nextLibVersion(v)) + chain.append(nextLibVersion(v)); + return chain; +} + +QJsonObject LibraryVersionMigrator::migrate() const +{ + QJsonObject result = _json; + QJsonArray nodes = result["nodes"].toArray(); + + for (LibVersion v = _source; v != _target; v = nextLibVersion(v)) + nodes = applyStep(nodes, migrationStepTable(v)); + + result["nodes"] = nodes; + result["libraryVersion"] = libVersionToString(_target); + return result; +} + +QJsonArray LibraryVersionMigrator::applyStep( + const QJsonArray& nodes, const QVector& table) +{ + if (table.isEmpty()) + return nodes; + + QJsonArray result; + for (const auto& item : nodes) { + auto nodeObj = item.toObject(); + auto typeName = nodeObj["typeName"].toString(); + + for (const auto& entry : table) { + if (entry.oldTypeName != typeName) + continue; + + nodeObj["typeName"] = entry.newTypeName; + + if (!entry.propertyKeyRenames.isEmpty() || + !entry.migratedPropertyDefaults.isEmpty()) { + auto props = nodeObj["properties"].toObject(); + + for (auto it = entry.propertyKeyRenames.begin(); + it != entry.propertyKeyRenames.end(); ++it) { + if (props.contains(it.key())) { + props[it.value()] = props[it.key()]; + props.remove(it.key()); + } + } + + for (auto it = entry.migratedPropertyDefaults.begin(); + it != entry.migratedPropertyDefaults.end(); ++it) { + props[it.key()] = it.value(); + } + + nodeObj["properties"] = props; + } + break; + } + + result.append(nodeObj); + } + return result; +} + +bool LibraryVersionMigrator::looksLegacy(const QJsonObject& json) +{ + auto nodes = json["nodes"].toArray(); + for (const auto& item : nodes) { + auto typeName = item.toObject()["typeName"].toString(); + for (const auto& entry : v2ToV3Table()) { + // Entries where oldTypeName == newTypeName (blend, cell, + // linecell, solidcell) only changed which class backs that + // name — the name itself is just as valid in a current-version + // file, so it can't be used as a legacy signal. Only count + // typeNames that were actually removed/renamed. + if (entry.oldTypeName == entry.newTypeName) + continue; + if (entry.oldTypeName == typeName) + return true; + } + } + return false; +} diff --git a/src/texturelab/libraries/libraryversionmigrator.h b/src/texturelab/libraries/libraryversionmigrator.h new file mode 100644 index 00000000..4df3507e --- /dev/null +++ b/src/texturelab/libraries/libraryversionmigrator.h @@ -0,0 +1,70 @@ +#pragma once + +#include "libversion.h" +#include +#include +#include +#include +#include +#include + +// One node's identity/shape change for a single version step (from -> next). +struct NodeTypeMigration { + QString oldTypeName; + QString newTypeName; + + // Rare: a property key that was renamed on an otherwise-equivalent + // node. Old key -> new key. Empty for every step table that exists + // today (see libraryversionmigrator.cpp). + QMap propertyKeyRenames; + + // Values for properties that only exist on the *new* node, set to + // whatever reproduces the old node's look instead of the new node's + // normal default. E.g. bevelv2 added an "invert" toggle (new-node + // default: false) that must be `true` for a migrated bevel node to + // match the old bevel's polarity. Only applied to nodes going through + // this migration step — a freshly-added bevelv2 node still gets its + // ordinary library default. Key -> value (any QJsonValue). + QJsonObject migratedPropertyDefaults; +}; + +// Returns the migration table to apply when stepping from `from` to +// nextLibVersion(from). An empty vector means that step has no typeName +// changes at all (e.g. v1 -> v2 today). +QVector migrationStepTable(LibVersion from); + +// Migrates a project's raw JSON from whatever library version it declares +// up to a target version (defaults to the current one), walking one +// version step at a time. Pure data transform: only QJsonObject / +// QJsonArray / QString are touched. No Library, TextureNode, Prop, or +// OpenGL/GPU resource is created anywhere in this class. +class LibraryVersionMigrator { +public: + explicit LibraryVersionMigrator(QJsonObject projectJson); + + LibVersion sourceVersion() const { return _source; } + LibVersion targetVersion() const { return _target; } + void setTargetVersion(LibVersion version) { _target = version; } + + bool needsMigration() const; + + // The chain of versions that migrate() will step into, e.g. [V2, V3] + // when migrating a V1 file up to V3. Empty if needsMigration() is + // false. Intended for building a human-readable "v1 -> v2 -> v3" + // message. + QList versionsCrossed() const; + + // Returns the migrated project JSON. Does not mutate the JSON passed + // to the constructor. Idempotent. Sets "libraryVersion" on the result + // to libVersionToString(targetVersion()). + QJsonObject migrate() const; + +private: + QJsonObject _json; + LibVersion _source; + LibVersion _target; + + static QJsonArray applyStep(const QJsonArray& nodes, + const QVector& table); + static bool looksLegacy(const QJsonObject& json); +}; diff --git a/src/texturelab/libraries/libv3.h b/src/texturelab/libraries/libv3.h new file mode 100644 index 00000000..e77b269f --- /dev/null +++ b/src/texturelab/libraries/libv3.h @@ -0,0 +1,224 @@ +#pragma once + +#include "../models.h" +#include "v3/curvenode.h" +#include + +// --------------------------------------------------------------------------- +// Phase 1 — Single-pass filter / color nodes +// --------------------------------------------------------------------------- + +class EdgeDetectNode : public TextureNode { +public: + void init() override; +}; + +class HighpassNode : public TextureNode { +public: + void init() override; +}; + +class EmbossNode : public TextureNode { +public: + void init() override; +}; + +class VibranceNode : public TextureNode { +public: + void init() override; +}; + +class ColorToMaskNode : public TextureNode { +public: + void init() override; +}; + +class SetAlphaNode : public TextureNode { +public: + void init() override; +}; + +class ToonGradientNode : public TextureNode { +public: + void init() override; +}; + +class AutoLevelsNode : public TextureNode { +public: + void init() override; + std::shared_ptr createRenderer() override; + std::shared_ptr createRenderData() override; +}; + +// --------------------------------------------------------------------------- +// Phase 2 — Generator nodes +// --------------------------------------------------------------------------- + +class Bricks2Node : public TextureNode { +public: + void init() override; +}; + +class DirectionalScratchesNode : public TextureNode { +public: + void init() override; +}; + +class RoughGrainNode : public TextureNode { +public: + void init() override; +}; + +class VoronoiFractalNode : public TextureNode { +public: + void init() override; +}; + +class TruchetNode : public TextureNode { +public: + void init() override; +}; + +class FBMDomainWarpNode : public TextureNode { +public: + void init() override; +}; + +class PerlinNoiseNode : public TextureNode { +public: + void init() override; +}; + +class PerlinNoise3DNode : public TextureNode { +public: + void init() override; +}; + +// --------------------------------------------------------------------------- +// Phase 3 — Multi-pass nodes +// --------------------------------------------------------------------------- + +class BlurHQNode : public TextureNode { +public: + void init() override; + std::shared_ptr createRenderer() override; + std::shared_ptr createRenderData() override; +}; + +class DistanceTransformNode : public TextureNode { +public: + void init() override; + std::shared_ptr createRenderer() override; + std::shared_ptr createRenderData() override; +}; + +class ColorSpreadNode : public TextureNode { +public: + void init() override; + std::shared_ptr createRenderer() override; + std::shared_ptr createRenderData() override; +}; + +class HeightBlendNode : public TextureNode { +public: + void init() override; +}; + +class BlendV3Node : public TextureNode { +public: + void init() override; +}; + +class MakeItTileNode : public TextureNode { +public: + void init() override; +}; + +class NormalMapV3Node : public TextureNode { +public: + void init() override; +}; + +class BevelV2Node : public TextureNode { +public: + virtual void init() override; + std::shared_ptr createRenderer() override; + std::shared_ptr createRenderData() override; +}; + +class AmbientOcclusionNode : public TextureNode { +public: + virtual void init() override; +}; + +class CurvatureNode : public TextureNode { +public: + virtual void init() override; +}; + +class MaskedBlurNode : public TextureNode { +public: + virtual void init() override; +}; + +class RaysNode : public TextureNode { +public: + virtual void init() override; +}; + +class SwirlNode : public TextureNode { +public: + virtual void init() override; +}; + +class FloodFillV2Node : public TextureNode { +public: + virtual void init() override; + std::shared_ptr createRenderer() override; + std::shared_ptr createRenderData() override; +}; + +class FloodFillV2ToColorNode : public TextureNode { +public: + virtual void init() override; +}; + +class FloodFillV2ToRandomColorNode : public TextureNode { +public: + virtual void init() override; +}; + +class FloodFillV2ToRandomIntensityNode : public TextureNode { +public: + virtual void init() override; +}; + +class FloodFillV2ToBBoxNode : public TextureNode { +public: + virtual void init() override; +}; + +class FloodFillV2ToGradientNode : public TextureNode { +public: + virtual void init() override; +}; + +class FloodFillV2SamplerNode : public TextureNode { +public: + virtual void init() override; +}; + +class CellV3Node : public TextureNode { +public: + void init() override; +}; + +class LineCellV3Node : public TextureNode { +public: + void init() override; +}; + +class SolidCellV3Node : public TextureNode { +public: + void init() override; +}; diff --git a/src/texturelab/libraries/libversion.cpp b/src/texturelab/libraries/libversion.cpp new file mode 100644 index 00000000..a67a2b0b --- /dev/null +++ b/src/texturelab/libraries/libversion.cpp @@ -0,0 +1,49 @@ +#include "libversion.h" +#include "library.h" + +LibVersion currentLibVersion() { return kCurrentLibVersion; } + +LibVersion libVersionFromString(const QString& str) +{ + auto s = str.trimmed().toLower(); + if (s == "v1") + return LibVersion::V1; + if (s == "v2") + return LibVersion::V2; + if (s == "v3") + return LibVersion::V3; + + return currentLibVersion(); +} + +QString libVersionToString(LibVersion version) +{ + switch (version) { + case LibVersion::V1: return "v1"; + case LibVersion::V2: return "v2"; + case LibVersion::V3: return "v3"; + } + return "v3"; +} + +bool hasNextLibVersion(LibVersion version) +{ + return version != currentLibVersion(); +} + +LibVersion nextLibVersion(LibVersion version) +{ + return static_cast(static_cast(version) + 1); +} + +Library* createLibraryForVersion(LibVersion version) +{ + switch (version) { + case LibVersion::V1: + case LibVersion::V2: + return createLibraryV2(); + case LibVersion::V3: + return createLibraryV3(); + } + return createLibraryV3(); +} diff --git a/src/texturelab/libraries/libversion.h b/src/texturelab/libraries/libversion.h new file mode 100644 index 00000000..7410a36e --- /dev/null +++ b/src/texturelab/libraries/libversion.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +class Library; + +// Contiguous, ordered library versions. Adding a new version means: add the +// enum value here, move kCurrentLibVersion, extend createLibraryForVersion(), +// and add a migration step table in libraryversionmigrator.cpp. +enum class LibVersion { V1 = 0, V2 = 1, V3 = 2 }; + +constexpr LibVersion kCurrentLibVersion = LibVersion::V3; + +LibVersion currentLibVersion(); + +// Parses "v1"/"v2"/"v3" (case-insensitive). Unrecognized/empty strings fall +// back to currentLibVersion(). +LibVersion libVersionFromString(const QString& str); +QString libVersionToString(LibVersion version); + +bool hasNextLibVersion(LibVersion version); +LibVersion nextLibVersion(LibVersion version); + +// Returns the node library that should be used to load/edit a project at +// the given version. V1 and V2 currently share createLibraryV2() since the +// v1->v2 step never removed or renamed anything. +Library* createLibraryForVersion(LibVersion version); diff --git a/src/texturelab/libraries/v1/maprange.cpp b/src/texturelab/libraries/v1/maprange.cpp index 03af7f4f..759882a0 100644 --- a/src/texturelab/libraries/v1/maprange.cpp +++ b/src/texturelab/libraries/v1/maprange.cpp @@ -18,10 +18,8 @@ void MapRangeNode::init() { vec4 col = texture(color,uv); - // color range coming in float inDiff = prop_in_max - prop_in_min; - col = (col-prop_in_min) / inDiff; - + col.rgb = (col.rgb - prop_in_min) / inDiff; float outDiff = prop_out_max - prop_out_min; col.rgb = prop_out_min + col.rgb * vec3(outDiff); diff --git a/src/texturelab/libraries/v1/tile.cpp b/src/texturelab/libraries/v1/tile.cpp index 95295778..ee890a2d 100644 --- a/src/texturelab/libraries/v1/tile.cpp +++ b/src/texturelab/libraries/v1/tile.cpp @@ -14,8 +14,8 @@ void TileNode::init() this->addFloatProp("brickWidth", "Tile Width", 1.0, 0, 1, 0.01); this->addFloatProp("brickHeight", "Tile Height", 1.0, 0, 1, 0.01); - this->addFloatProp("rows", "Rows", 6, 1, 20, 1); - this->addFloatProp("columns", "Columns", 6, 1, 20, 1); + this->addIntProp("rows", "Rows", 6, 1, 30, 1); + this->addIntProp("columns", "Columns", 6, 1, 30, 1); auto source = R""""( // offset for alternating rows diff --git a/src/texturelab/libraries/v2/tilesampler.cpp b/src/texturelab/libraries/v2/tilesampler.cpp index 99f77d09..88aa7408 100644 --- a/src/texturelab/libraries/v2/tilesampler.cpp +++ b/src/texturelab/libraries/v2/tilesampler.cpp @@ -14,15 +14,15 @@ void TileSamplerNode::init() this->addEnumProp("blendType", "Blend Type", {"Max", "Add"}); - this->addIntProp("rows", "Row Count", 8, 0, 15, 1); - this->addIntProp("columns", "Column Count", 8, 0, 15, 1); + this->addIntProp("rows", "Row Count", 8, 0, 30, 1); + this->addIntProp("columns", "Column Count", 8, 0, 30, 1); auto posProps = this->createGroup("Position"); - posProps->add(this->addFloatProp("offset", "Offset", 0.5, 0, 1, 0.1)); + posProps->add(this->addFloatProp("offset", "Offset", 0.0, 0, 1, 0.1)); posProps->add( this->addEnumProp("offset_axis", "Offset Axis", {"X Axis", "Y Axis"})); posProps->add( - this->addIntProp("offset_interval", "Offset Interval", 1, 1, 5, 1)); + this->addIntProp("offset_interval", "Offset Interval", 2, 1, 5, 1)); auto rotProps = this->createGroup("Rotation"); rotProps->add(this->addFloatProp("rot", "Rotation", 0, 0, 360, 0.1)); diff --git a/src/texturelab/libraries/v3/ambientocclusion.cpp b/src/texturelab/libraries/v3/ambientocclusion.cpp new file mode 100644 index 00000000..7efc94bc --- /dev/null +++ b/src/texturelab/libraries/v3/ambientocclusion.cpp @@ -0,0 +1,52 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +void AmbientOcclusionNode::init() +{ + this->title = "Ambient Occlusion"; + this->addInput("height"); + + this->addFloatProp("radius", "Radius", 0.05, 0.001, 1.0, 0.005); + this->addIntProp("samples", "Samples", 64, 4, 64, 4); + this->addFloatProp("intensity", "Intensity", 1.0, 0.1, 5.0, 0.1); + this->addFloatProp("bias", "Bias", 0.01, 0.0, 0.1, 0.005); + this->addFloatProp("height_scale", "Height Scale", 1.0, 0.01, 1.0, 0.01); + + auto source = R""""( + vec4 process(vec2 uv) + { + float centerH = texture(height, uv).r * prop_height_scale; + float occlusion = 0.0; + float sampleCount = float(prop_samples); + float radius = prop_radius; + + for (int i = 0; i < prop_samples; i++) + { + // randomFloat(index) uses _randomStart (per-pixel) + _seed + index + float angle = randomFloat(i * 2) * 6.28318530718; + vec2 dir = vec2(cos(angle), sin(angle)); + float dist = (randomFloat(i * 2 + 1) * 0.75 + 0.25) * radius; + + vec2 sampleUV = uv + dir * dist; + float sampleH = texture(height, sampleUV).r * prop_height_scale; + + // Height difference — higher neighbors block light + float dh = sampleH - centerH; + + // Scale by inverse distance so closer samples matter more + float distFactor = 1.0 - (dist / radius); + float contribution = max(dh - prop_bias, 0.0) * distFactor; + + occlusion += contribution; + } + + occlusion = occlusion / sampleCount; + occlusion = 1.0 - clamp(occlusion * prop_intensity, 0.0, 1.0); + + return vec4(vec3(occlusion), 1.0); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/autolevels.cpp b/src/texturelab/libraries/v3/autolevels.cpp new file mode 100644 index 00000000..2d93f71c --- /dev/null +++ b/src/texturelab/libraries/v3/autolevels.cpp @@ -0,0 +1,267 @@ +#include "../../graphics/noderenderer.h" +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +#include +#include + +// GPU min/max reduction via progressive 2×2 downsampling. +// Pipeline: +// 1. Init pass — convert full-res source to luma (per_channel=false) +// or keep RGB (per_channel=true) +// 2. Min chain — halve resolution each step, computing component-wise min +// 3. Max chain — same for max +// Both chains converge to a 1×1 texture storing the global min/max. +// 4. Apply pass — remap source using the 1×1 min/max values + gamma. +// +// This gives a true per-frame automatic levels without any CPU readback. +// Follow with a Curve or Map Range node for further tonal shaping. + +// ============================================================================ +// AutoLevelsRenderData +// ============================================================================ +struct AutoLevelsRenderData : public NodeRenderData { + float gamma = 1.0f; + bool perChannel = false; +}; + +// ============================================================================ +// AutoLevelsRenderer +// ============================================================================ +class AutoLevelsRenderer : public NodeTextureRenderer { +public: + void render(NodeRenderContext& ctx, const NodeRenderData& baseData) override + { + auto& data = static_cast(baseData); + auto gl = ctx.gl; + auto cache = ctx.cache; + int w = ctx.textureWidth; + int h = ctx.textureHeight; + + if (ctx.inputs.isEmpty() || ctx.inputs[0].textureId == 0) { + cache->bindFboToTexture(ctx.outputTextureId); + gl->glViewport(0, 0, w, h); + gl->glClearColor(0, 0, 0, 1); + gl->glClear(GL_COLOR_BUFFER_BIT); + return; + } + + GLuint srcTex = ctx.inputs[0].textureId; + GLuint initSh = cache->getOrCompileShader("al_init", stdVert(), initFrag()); + GLuint minSh = cache->getOrCompileShader("al_min", stdVert(), reduceFrag(true)); + GLuint maxSh = cache->getOrCompileShader("al_max", stdVert(), reduceFrag(false)); + GLuint applySh = cache->getOrCompileShader("al_apply", stdVert(), applyFrag()); + + // --- 1. Init pass (full resolution) --- + // per_channel=false → store luma in RGB so all 3 channels reduce identically + // per_channel=true → keep RGB as-is + GLuint initTex = cache->acquireTexture(w, h); + cache->bindFboToTexture(initTex); + ctx.useShader(initSh); + ctx.bindTexture(initSh, "u_src", srcTex, 0); + gl->glUniform1i( + gl->glGetUniformLocation(initSh, "u_per_channel"), + data.perChannel ? 1 : 0); + ctx.drawQuad(); + + // --- 2 & 3. Min/Max reduction chains --- + GLuint minTex = reduce(ctx, cache, gl, initTex, w, h, minSh); + GLuint maxTex = reduce(ctx, cache, gl, initTex, w, h, maxSh); + + cache->releaseTexture(initTex); + + // --- 4. Apply pass (full resolution) --- + cache->bindFboToTexture(ctx.outputTextureId); + ctx.useShader(applySh); + ctx.bindTexture(applySh, "u_src", srcTex, 0); + ctx.bindTexture(applySh, "u_min", minTex, 1); + ctx.bindTexture(applySh, "u_max", maxTex, 2); + gl->glUniform1f( + gl->glGetUniformLocation(applySh, "u_gamma"), + data.gamma); + gl->glUniform1i( + gl->glGetUniformLocation(applySh, "u_per_channel"), + data.perChannel ? 1 : 0); + ctx.drawQuad(); + + cache->releaseTexture(minTex); + cache->releaseTexture(maxTex); + } + +private: + // Progressively halve the texture, applying the given reduce shader + // (min or max) at each step. Returns a 1×1 texture the caller must release. + static GLuint reduce(NodeRenderContext& ctx, RenderResourceCache* cache, + QOpenGLFunctions_3_2_Core* gl, + GLuint srcTex, int w, int h, GLuint shader) + { + GLuint current = srcTex; + int cw = w, ch = h; + bool ownsCurrent = false; + + while (cw > 1 || ch > 1) { + int nextW = std::max(cw / 2, 1); + int nextH = std::max(ch / 2, 1); + GLuint next = cache->acquireTexture(nextW, nextH); + + // Bind FBO manually — we don't use ctx.useShader here because + // it would force the viewport to the node's final output size. + cache->bindFboToTexture(next); + gl->glViewport(0, 0, nextW, nextH); + gl->glUseProgram(shader); + + // Tell the shader how big the SOURCE texture is so it can + // compute the correct half-texel offset for 2×2 sampling. + gl->glUniform2f( + gl->glGetUniformLocation(shader, "u_src_size"), + float(cw), float(ch)); + + ctx.bindTexture(shader, "u_src", current, 0); + ctx.drawQuad(); + + if (ownsCurrent) cache->releaseTexture(current); + current = next; + ownsCurrent = true; + cw = nextW; + ch = nextH; + } + + return current; // 1×1 result, caller must release + } + + static QString stdVert() { return RenderResourceCache::standardVertexSource(); } + + // 1. Init: convert source to values suitable for reduction + static QString initFrag() + { + return R""""( + #version 150 + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_src; + uniform int u_per_channel; + + void main() + { + vec4 c = texture(u_src, v_texCoord); + if (u_per_channel == 1) { + // Keep RGB — reduce each channel independently + fragColor = vec4(c.rgb, 1.0); + } else { + // Replicate luma to RGB so all channels carry the same value; + // the min/max of any channel then equals the global luma min/max. + float luma = dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)); + fragColor = vec4(luma, luma, luma, 1.0); + } + } + )""""; + } + + // 2/3. Reduction: component-wise min or max over a 2×2 block of the source + static QString reduceFrag(bool isMin) + { + QString op = isMin ? "min" : "max"; + return QString(R""""( + #version 150 + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_src; + uniform vec2 u_src_size; // size of the INPUT texture for this pass + + void main() + { + // Half-texel offset in source-texture space centres the 4 samples + // on the 2×2 block that maps to this output pixel. + vec2 ht = 0.5 / u_src_size; + vec2 uv = v_texCoord; + + vec4 s00 = texture(u_src, uv + vec2(-ht.x, -ht.y)); + vec4 s10 = texture(u_src, uv + vec2( ht.x, -ht.y)); + vec4 s01 = texture(u_src, uv + vec2(-ht.x, ht.y)); + vec4 s11 = texture(u_src, uv + vec2( ht.x, ht.y)); + + fragColor = %1(%1(s00, s10), %1(s01, s11)); + } + )"""").arg(op); + } + + // 4. Apply: remap using the 1×1 min/max textures + gamma + static QString applyFrag() + { + return R""""( + #version 150 + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_src; + uniform sampler2D u_min; + uniform sampler2D u_max; + uniform float u_gamma; + uniform int u_per_channel; + + float remap(float v, float lo, float hi) { + float range = max(hi - lo, 0.001); + float t = clamp((v - lo) / range, 0.0, 1.0); + // Gamma > 1 brightens midtones; gamma < 1 darkens. + return (u_gamma > 0.999 && u_gamma < 1.001) + ? t + : pow(t, 1.0 / max(u_gamma, 0.001)); + } + + void main() + { + vec4 src = texture(u_src, v_texCoord); + // 1×1 textures — sample at the centre; exact UV doesn't matter. + vec4 lo = texture(u_min, vec2(0.5)); + vec4 hi = texture(u_max, vec2(0.5)); + + vec3 result; + if (u_per_channel == 1) { + // Independent per-channel remap (can shift colour balance) + result.r = remap(src.r, lo.r, hi.r); + result.g = remap(src.g, lo.g, hi.g); + result.b = remap(src.b, lo.b, hi.b); + } else { + // Luminance-preserving remap: scale RGB uniformly so hue is kept + float luma = dot(src.rgb, vec3(0.2126, 0.7152, 0.0722)); + float newLuma = remap(luma, lo.r, hi.r); + float scale = (luma > 0.0001) ? newLuma / luma : 0.0; + result = clamp(src.rgb * scale, 0.0, 1.0); + } + + fragColor = vec4(result, src.a); + } + )""""; + } +}; + +// ============================================================================ +// AutoLevelsNode +// ============================================================================ +void AutoLevelsNode::init() +{ + this->title = "Auto Levels"; + + this->addInput("image"); + + this->addFloatProp("gamma", "Midpoint Gamma", 1.0, 0.1, 4.0, 0.05); + this->addBoolProp ("per_channel", "Per Channel", false); +} + +std::shared_ptr AutoLevelsNode::createRenderer() +{ + return std::make_shared(); +} + +std::shared_ptr AutoLevelsNode::createRenderData() +{ + auto data = std::make_shared(); + if (auto* p = dynamic_cast(getProp("gamma"))) + data->gamma = static_cast(p->value); + if (auto* p = dynamic_cast(getProp("per_channel"))) + data->perChannel = p->value; + return data; +} diff --git a/src/texturelab/libraries/v3/bevelv2.cpp b/src/texturelab/libraries/v3/bevelv2.cpp new file mode 100644 index 00000000..d7dd5a6b --- /dev/null +++ b/src/texturelab/libraries/v3/bevelv2.cpp @@ -0,0 +1,302 @@ +#include "../../graphics/noderenderer.h" +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +#include +#include +#include + +// ============================================================================ +// BevelV2RenderData — parameters passed to the render thread +// ============================================================================ + +struct BevelV2RenderData : public NodeRenderData { + float distance = 50.0f; + float threshold = 0.5f; + int shape = 0; // 0=Linear, 1=Round, 2=Smooth + bool invert = false; + bool scaleInvariant = true; +}; + +// ============================================================================ +// BevelV2Renderer — JFA-based GPU bevel implementation +// ============================================================================ + +class BevelV2Renderer : public NodeTextureRenderer { +public: + void render(NodeRenderContext& ctx, const NodeRenderData& baseData) override + { + auto& data = static_cast(baseData); + auto gl = ctx.gl; + auto cache = ctx.cache; + + int w = ctx.textureWidth; + int h = ctx.textureHeight; + + // No input connected — output black + if (ctx.inputs.isEmpty() || ctx.inputs[0].textureId == 0) { + cache->bindFboToTexture(ctx.outputTextureId); + gl->glViewport(0, 0, w, h); + gl->glClearColor(0, 0, 0, 1); + gl->glClear(GL_COLOR_BUFFER_BIT); + return; + } + + // Compile shaders (cached after first call) + GLuint seedShader = + cache->getOrCompileShader("jfa_seed", standardVert(), seedFrag()); + GLuint jfaShader = + cache->getOrCompileShader("jfa_step", standardVert(), jfaFrag()); + GLuint bevelShader = + cache->getOrCompileShader("jfa_bevel", standardVert(), bevelFrag()); + + // Acquire two intermediate textures for ping-pong + GLuint texA = cache->acquireTexture(w, h); + GLuint texB = cache->acquireTexture(w, h); + + // --- Seed initialization --- + cache->bindFboToTexture(texA); + ctx.useShader(seedShader); + ctx.bindTexture(seedShader, "image", ctx.inputs[0].textureId, 0); + gl->glUniform1f(gl->glGetUniformLocation(seedShader, "u_threshold"), + data.threshold); + gl->glUniform1i(gl->glGetUniformLocation(seedShader, "u_invert"), + data.invert ? 1 : 0); + ctx.drawQuad(); + + // --- JFA iteration (ping-pong) --- + int maxDim = std::max(w, h); + int stepSize = maxDim / 2; + + while (stepSize >= 1) { + cache->bindFboToTexture(texB); + ctx.useShader(jfaShader); + ctx.bindTexture(jfaShader, "u_input", texA, 0); + gl->glUniform1i(gl->glGetUniformLocation(jfaShader, "u_stepSize"), + stepSize); + ctx.drawQuad(); + + std::swap(texA, texB); + stepSize /= 2; + } + + // --- Final pass: Distance field → bevel height --- + cache->bindFboToTexture(ctx.outputTextureId); + ctx.useShader(bevelShader); + ctx.bindTexture(bevelShader, "u_jfa", texA, 0); + gl->glUniform1f(gl->glGetUniformLocation(bevelShader, "u_distance"), + data.distance); + gl->glUniform1i(gl->glGetUniformLocation(bevelShader, "u_shape"), + data.shape); + gl->glUniform1i(gl->glGetUniformLocation(bevelShader, "u_invert"), + data.invert ? 1 : 0); + gl->glUniform1i( + gl->glGetUniformLocation(bevelShader, "u_scaleInvariant"), + data.scaleInvariant ? 1 : 0); + ctx.drawQuad(); + } + +private: + static QString standardVert() + { + return RenderResourceCache::standardVertexSource(); + } + + static QString seedFrag() + { + return R""""( + #version 150 core + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D image; + uniform vec2 _textureSize; + uniform float u_threshold; + uniform int u_invert; + + void main() { + vec2 uv = v_texCoord; + float v = texture(image, uv).r; + + // Black pixels (below threshold) are seeds by default — + // JFA spreads distance from them into white regions, so + // white shapes end up raised and black background stays + // flat. `invert` swaps which side is treated as the seed + // (matching the old bevel node, whose two-sided signed + // distance field meant flipping its single "invert" had + // the effect of swapping which side the flat plateau fell + // on, on top of flipping the output polarity below). + bool isSeed = (u_invert == 1) ? (v >= u_threshold) + : (v < u_threshold); + + if (isSeed) + fragColor = vec4(uv, v, 1.0); // Seed: store own UV + else + fragColor = vec4(-1.0, -1.0, v, 0.0); // No seed + } + )""""; + } + + static QString jfaFrag() + { + return R""""( + #version 150 core + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_input; + uniform vec2 _textureSize; + uniform int u_stepSize; + + void main() { + vec2 uv = v_texCoord; + vec2 texel = vec2(1.0) / _textureSize; + vec4 best = texture(u_input, uv); + float bestDist = (best.a < 0.5) ? 9999.0 : length(uv - best.xy); + + // Check 3x3 neighborhood at current step size + for (int y = -1; y <= 1; y++) { + for (int x = -1; x <= 1; x++) { + if (x == 0 && y == 0) continue; + + vec2 offset = vec2(float(x), float(y)) + * float(u_stepSize) * texel; + vec4 neighbor = texture(u_input, uv + offset); + + if (neighbor.a < 0.5) continue; // No seed + + float d = length(uv - neighbor.xy); + if (d < bestDist) { + bestDist = d; + best = neighbor; + } + } + } + + fragColor = best; + } + )""""; + } + + static QString bevelFrag() + { + return R""""( + #version 150 core + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_jfa; + uniform vec2 _textureSize; + uniform float u_distance; + uniform int u_shape; + uniform int u_invert; + uniform int u_scaleInvariant; + + #define SHAPE_LINEAR 0 + #define SHAPE_ROUND 1 + #define SHAPE_SMOOTH 2 + + void main() { + vec2 uv = v_texCoord; + vec4 data = texture(u_jfa, uv); + + if (data.a < 0.5) { + // No nearest seed found + fragColor = vec4(0.0, 0.0, 0.0, 1.0); + return; + } + + // Convert UV-space distance to pixel distance + float dist = length(uv - data.xy) + * max(_textureSize.x, _textureSize.y); + + // Scale-invariant: normalize against texture resolution so + // the bevel width in UV space stays consistent across + // resolutions (512 is the reference size the prop range + // was tuned against). Disabled, `distance` is read as a + // raw pixel count instead — matching the old (v1/v2) Bevel + // node, which always operated in absolute pixel terms. + float pixelDistance = (u_scaleInvariant == 1) + ? u_distance * (max(_textureSize.x, _textureSize.y) / 512.0) + : u_distance; + float t = clamp(dist / pixelDistance, 0.0, 1.0); + + float bevel; + if (u_shape == SHAPE_ROUND) { + // Circular cross-section: quarter-circle falloff + bevel = sqrt(1.0 - (1.0 - t) * (1.0 - t)); + } else if (u_shape == SHAPE_SMOOTH) { + // S-curve: smoothstep for gentle transitions + bevel = smoothstep(0.0, 1.0, t); + } else { + // Linear ramp (default) + bevel = t; + } + + // if (u_invert == 1) + // bevel = 1.0 - bevel; + + fragColor = vec4(vec3(bevel), 1.0); + } + )""""; + } +}; + +// ============================================================================ +// BevelV2Node +// ============================================================================ + +void BevelV2Node::init() +{ + this->title = "Bevel V2"; + this->addInput("image"); + this->addFloatProp("distance", "Distance", 50.0, 0.0, 200.0, 0.5); + this->addFloatProp("threshold", "Threshold", 0.5, 0.0, 1.0, 0.01); + this->addEnumProp("shape", "Shape", {"Linear", "Round", "Smooth"}); + this->addBoolProp("invert", "Invert", false); + this->addBoolProp("scaleInvariant", "Scale Invariant", true); + + // Passthrough shader for initialization (not used during rendering — + // custom renderer handles all passes) + auto source = R""""( + vec4 process(vec2 uv) + { + return texture(image, uv); + } + )""""; + this->setShaderSource(source); +} + +std::shared_ptr BevelV2Node::createRenderer() +{ + return std::make_shared(); +} + +std::shared_ptr BevelV2Node::createRenderData() +{ + auto data = std::make_shared(); + + auto distProp = static_cast(this->getProp("distance")); + if (distProp) + data->distance = distProp->value; + + auto threshProp = static_cast(this->getProp("threshold")); + if (threshProp) + data->threshold = threshProp->value; + + auto shapeProp = static_cast(this->getProp("shape")); + if (shapeProp) + data->shape = shapeProp->index; + + auto invertProp = static_cast(this->getProp("invert")); + if (invertProp) + data->invert = invertProp->value; + + auto scaleInvariantProp = + static_cast(this->getProp("scaleInvariant")); + if (scaleInvariantProp) + data->scaleInvariant = scaleInvariantProp->value; + + return data; +} diff --git a/src/texturelab/libraries/v3/blendv3.cpp b/src/texturelab/libraries/v3/blendv3.cpp new file mode 100644 index 00000000..e9e5cc68 --- /dev/null +++ b/src/texturelab/libraries/v3/blendv3.cpp @@ -0,0 +1,140 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// reference: +// https://ssp.impulsetrain.com/porterduff.html +// https://github.com/dpt/Porter-Duff/blob/master/Porter-Duff.md + +void BlendV3Node::init() +{ + this->title = "Blend"; + + this->addInput("colorA"); // top / foreground + this->addInput("colorB"); // bottom / background + this->addInput("opacity"); // optional mask + + // Indices 0-8 match the v1 blend node for consistency. + // New modes are appended from index 9 onward. + this->addEnumProp("type", "Type", + { + "Multiply", // 0 + "Add", // 1 + "Subtract", // 2 + "Divide", // 3 + "Max", // 4 + "Min", // 5 + "Switch", // 6 (shows colorA — same as v1) + "Overlay", // 7 (fixed: correct 2× factor) + "Screen", // 8 + "Soft Light", // 9 + "Hard Light", // 10 + "Color Dodge", // 11 + "Color Burn", // 12 + "Linear Dodge", // 13 + "Linear Burn", // 14 + "Difference", // 15 + "Exclusion", // 16 + "Normal", // 17 + }); + this->addFloatProp("opacity", "Opacity", 1.0, 0.0, 1.0, 0.01); + + this->setShaderSource(R""""( + vec3 blend_screen(vec3 a, vec3 b) { + return 1.0 - (1.0 - a) * (1.0 - b); + } + + // Standard overlay: base is bottom layer, blend is top layer. + // if base < 0.5: 2*base*blend, else 1 - 2*(1-base)*(1-blend) + // Uses explicit per-channel conditionals to avoid evaluating both + // branches simultaneously, which can produce NaN/Inf with HDR inputs. + vec3 blend_overlay(vec3 base, vec3 blend) { + vec3 dark = 2.0 * base * blend; + vec3 light = 1.0 - 2.0 * (1.0 - base) * (1.0 - blend); + return vec3( + base.r < 0.5 ? dark.r : light.r, + base.g < 0.5 ? dark.g : light.g, + base.b < 0.5 ? dark.b : light.b + ); + } + + // Pegtop / W3C two-case approximation for soft light. + // base = bottom, blend = top (light source). + vec3 blend_soft_light(vec3 base, vec3 blend) { + vec3 dark = 2.0 * base * blend + base * base * (1.0 - 2.0 * blend); + vec3 light = 2.0 * base * (1.0 - blend) + sqrt(clamp(base, 0.0, 1.0)) * (2.0 * blend - 1.0); + return vec3( + blend.r < 0.5 ? dark.r : light.r, + blend.g < 0.5 ? dark.g : light.g, + blend.b < 0.5 ? dark.b : light.b + ); + } + + // Hard light = overlay with layers swapped. + vec3 blend_hard_light(vec3 base, vec3 blend) { + return blend_overlay(blend, base); + } + + vec3 blend_color_dodge(vec3 base, vec3 blend) { + return clamp(base / max(1.0 - blend, vec3(1e-4)), 0.0, 1.0); + } + + vec3 blend_color_burn(vec3 base, vec3 blend) { + return clamp(1.0 - (1.0 - base) / max(blend, vec3(1e-4)), 0.0, 1.0); + } + + vec4 process(vec2 uv) + { + float finalOpacity = prop_opacity; + if (opacity_connected) + finalOpacity *= texture(opacity, uv).r; + + vec4 colA = texture(colorA, uv); // top / foreground + vec4 colB = texture(colorB, uv); // bottom / background + vec3 result = colB.rgb; + + if (prop_type == 0) // Multiply + result = colA.rgb * colB.rgb; + else if (prop_type == 1) // Add + result = colA.rgb + colB.rgb; + else if (prop_type == 2) // Subtract + result = colB.rgb - colA.rgb; + else if (prop_type == 3) // Divide + result = colB.rgb / max(colA.rgb, vec3(1e-4)); + else if (prop_type == 4) // Max + result = max(colA.rgb, colB.rgb); + else if (prop_type == 5) // Min + result = min(colA.rgb, colB.rgb); + else if (prop_type == 6) // Switch (show colorA) + result = colA.rgb; + else if (prop_type == 7) // Overlay (fixed) + result = blend_overlay(colB.rgb, colA.rgb); + else if (prop_type == 8) // Screen + result = blend_screen(colA.rgb, colB.rgb); + else if (prop_type == 9) // Soft Light + result = blend_soft_light(colB.rgb, colA.rgb); + else if (prop_type == 10) // Hard Light + result = blend_hard_light(colB.rgb, colA.rgb); + else if (prop_type == 11) // Color Dodge + result = blend_color_dodge(colB.rgb, colA.rgb); + else if (prop_type == 12) // Color Burn + result = blend_color_burn(colB.rgb, colA.rgb); + else if (prop_type == 13) // Linear Dodge (clamped Add) + result = clamp(colA.rgb + colB.rgb, 0.0, 1.0); + else if (prop_type == 14) // Linear Burn + result = clamp(colA.rgb + colB.rgb - 1.0, 0.0, 1.0); + else if (prop_type == 15) // Difference + result = abs(colA.rgb - colB.rgb); + else if (prop_type == 16) // Exclusion + result = colA.rgb + colB.rgb - 2.0 * colA.rgb * colB.rgb; + else // Normal (17) + result = colA.rgb; + + // Factor in the foreground's own alpha so transparent regions + // of the overlay let the background show through. + float blendFactor = finalOpacity * colA.a; + float outAlpha = blendFactor + colB.a * (1.0 - blendFactor); + return vec4(mix(colB.rgb, result, blendFactor), outAlpha); + } + )""""); +} diff --git a/src/texturelab/libraries/v3/blurhq.cpp b/src/texturelab/libraries/v3/blurhq.cpp new file mode 100644 index 00000000..69edba24 --- /dev/null +++ b/src/texturelab/libraries/v3/blurhq.cpp @@ -0,0 +1,192 @@ +#include "../../graphics/noderenderer.h" +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +#include +#include +#include + +// https://learnopengl.com/Advanced-Lighting/Bloom (separated Gaussian in GLSL) +// Wells, "Efficient Synthesis of Gaussian Filters by Cascaded Uniform Filters," +// IEEE PAMI 1986 +// True separated Gaussian blur: two 1D passes (H then V) give O(2N) samples +// vs O(N²) for a 2D kernel — making large-radius blurs practical on the GPU. +// Use in preference to the V2 Blur node whenever quality matters. + +// ============================================================================ +// BlurHQRenderData +// ============================================================================ +struct BlurHQRenderData : public NodeRenderData { + float radius = 8.0f; +}; + +// ============================================================================ +// BlurHQRenderer +// ============================================================================ +class BlurHQRenderer : public NodeTextureRenderer { +public: + void render(NodeRenderContext& ctx, const NodeRenderData& baseData) override + { + auto& data = static_cast(baseData); + auto gl = ctx.gl; + auto cache = ctx.cache; + int w = ctx.textureWidth; + int h = ctx.textureHeight; + + // No input — output black + if (ctx.inputs.isEmpty() || ctx.inputs[0].textureId == 0) { + cache->bindFboToTexture(ctx.outputTextureId); + gl->glViewport(0, 0, w, h); + gl->glClearColor(0, 0, 0, 1); + gl->glClear(GL_COLOR_BUFFER_BIT); + return; + } + + GLuint hShader = cache->getOrCompileShader( + "blurhq_horizontal", + RenderResourceCache::standardVertexSource(), + horizontalFrag()); + GLuint vShader = cache->getOrCompileShader( + "blurhq_vertical", + RenderResourceCache::standardVertexSource(), + verticalFrag()); + + // Intermediate texture for the horizontal pass result. + // GL_LINEAR is required for the bilinear tap trick in the shaders — + // sampling at fractional offsets must interpolate rather than snap. + GLuint intermediate = cache->acquireTexture(w, h); + gl->glBindTexture(GL_TEXTURE_2D, intermediate); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + + GLuint inputTex = ctx.inputs[0].textureId; + gl->glBindTexture(GL_TEXTURE_2D, inputTex); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + gl->glBindTexture(GL_TEXTURE_2D, 0); + + // --- Pass 1: horizontal blur --- + cache->bindFboToTexture(intermediate); + ctx.useShader(hShader); + ctx.bindTexture(hShader, "u_image", inputTex, 0); + gl->glUniform1f( + gl->glGetUniformLocation(hShader, "u_radius"), data.radius); + gl->glUniform2f( + gl->glGetUniformLocation(hShader, "_textureSize"), + float(w), float(h)); + ctx.drawQuad(); + + // --- Pass 2: vertical blur --- + cache->bindFboToTexture(ctx.outputTextureId); + ctx.useShader(vShader); + ctx.bindTexture(vShader, "u_image", intermediate, 0); + gl->glUniform1f( + gl->glGetUniformLocation(vShader, "u_radius"), data.radius); + gl->glUniform2f( + gl->glGetUniformLocation(vShader, "_textureSize"), + float(w), float(h)); + ctx.drawQuad(); + + // Restore GL_NEAREST on both textures — pooled textures are expected + // to be GL_NEAREST; the input texture is owned by another node. + gl->glBindTexture(GL_TEXTURE_2D, inputTex); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + gl->glBindTexture(GL_TEXTURE_2D, intermediate); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + gl->glBindTexture(GL_TEXTURE_2D, 0); + + cache->releaseTexture(intermediate); + } + +private: + // Shared Gaussian sampling code — axis is injected per-pass. + // + // Uses the bilinear tap trick: instead of sampling at each integer pixel + // offset i, adjacent taps i and i+1 are collapsed into a single fetch at + // the Gaussian-weighted midpoint between them. Hardware bilinear filtering + // then delivers the exact weighted average in one sample, halving fetch + // count and eliminating the discrete-step banding that arises from + // integer-only sampling on smooth gradients. + static QString gaussianBody(const QString& axis) + { + return QString(R""""( + #version 150 + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_image; + uniform vec2 _textureSize; + uniform float u_radius; + + void main() + { + vec2 uv = v_texCoord; + vec2 step = 1.0 / _textureSize; + + // Scale radius by resolution so the blur covers the same + // visual proportion regardless of texture size (512 = reference). + float pixelRadius = u_radius * (_textureSize.x / 512.0); + float sigma = max(pixelRadius / 3.0, 0.001); + float twoSigSq = 2.0 * sigma * sigma; + + // Center tap: G(0) = 1, no offset needed. + vec4 result = texture(u_image, uv); + float totalW = 1.0; + + int iRadius = int(ceil(pixelRadius)); + + // Bilinear tap trick: pair taps i and i+1, sample once at the + // weighted midpoint. GL_LINEAR on u_image makes the hardware + // perform the blend. Use float(i)*float(i) to avoid any + // potential int overflow at large radii. + for (int i = 1; i <= iRadius; i += 2) { + float fi = float(i); + float w0 = exp(-(fi * fi) / twoSigSq); + float w1 = (i + 1 <= iRadius) + ? exp(-((fi + 1.0) * (fi + 1.0)) / twoSigSq) + : 0.0; + float w = w0 + w1; + float offset = fi + w1 / w; + + vec2 o = %1 * offset * step; + result += texture(u_image, fract(uv + o)) * w; + result += texture(u_image, fract(uv - o)) * w; + totalW += 2.0 * w; + } + + fragColor = result / max(totalW, 0.0001); + } + )"""").arg(axis); + } + + static QString horizontalFrag() { return gaussianBody("vec2(1.0, 0.0)"); } + static QString verticalFrag() { return gaussianBody("vec2(0.0, 1.0)"); } +}; + +// ============================================================================ +// BlurHQNode +// ============================================================================ +void BlurHQNode::init() +{ + this->title = "Blur HQ"; + + this->addInput("image"); + + this->addFloatProp("radius", "Radius", 8.0, 0.5, 128.0, 0.5); +} + +std::shared_ptr BlurHQNode::createRenderer() +{ + return std::make_shared(); +} + +std::shared_ptr BlurHQNode::createRenderData() +{ + auto data = std::make_shared(); + auto* prop = dynamic_cast(getProp("radius")); + if (prop) data->radius = static_cast(prop->value); + return data; +} diff --git a/src/texturelab/libraries/v3/bricks2.cpp b/src/texturelab/libraries/v3/bricks2.cpp new file mode 100644 index 00000000..a90084ec --- /dev/null +++ b/src/texturelab/libraries/v3/bricks2.cpp @@ -0,0 +1,264 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +void Bricks2Node::init() +{ + this->title = "Bricks 2"; + + this->addInput("mortarMap"); + this->addInput("bevelMap"); + + this->addEnumProp("pattern", "Pattern", + {"Running Bond", "Stack Bond", "Herringbone", + "Basket Weave"}); + + this->addIntProp("rows", "Rows", 6, 1, 20, 1); + this->addIntProp("columns", "Columns", 6, 1, 20, 1); + this->addFloatProp("brickAspect", "Brick Aspect Ratio", 2.0, 0.5, 4.0, 0.1); + this->addFloatProp("offset", "Offset", 0.5, 0, 1, 0.1); + + // mortar + auto mortarProps = this->createGroup("Mortar"); + mortarProps->collapsed = false; + mortarProps->add( + this->addFloatProp("mortarWidth", "Mortar Width", 0.08, 0, 0.5, 0.01)); + mortarProps->add(this->addFloatProp("mortarMapStrength", + "Mortar Map Strength", 0.5, 0, 1, + 0.01)); + + // shape + auto shapeProps = this->createGroup("Shape"); + shapeProps->collapsed = false; + shapeProps->add(this->addFloatProp("shapeVariance", "Shape Variance", 0.0, + 0, 1, 0.01)); + shapeProps->add( + this->addFloatProp("roundness", "Roundness", 0.0, 0, 1, 0.01)); + + // bevel + auto bevelProps = this->createGroup("Bevel"); + bevelProps->collapsed = false; + bevelProps->add( + this->addFloatProp("bevelAmount", "Bevel Amount", 0.0, 0, 1, 0.01)); + bevelProps->add(this->addFloatProp("bevelMapStrength", + "Bevel Map Strength", 1.0, 0, 1, 0.01)); + + // height + auto heightProps = this->createGroup("Height"); + heightProps->collapsed = false; + heightProps->add( + this->addFloatProp("heightMin", "Height Min", 0.0, 0, 1, 0.05)); + heightProps->add( + this->addFloatProp("heightMax", "Height Max", 1.0, 0, 1, 0.05)); + heightProps->add( + this->addFloatProp("heightBalance", "Height Balance", 1.0, 0, 1, 0.05)); + heightProps->add( + this->addFloatProp("heightVariance", "Height Variance", 0, 0, 1, 0.05)); + + auto source = R""""( + // ===================== height variation ===================== + float calculateHeight(vec2 brickId) + { + float heightMin = prop_heightMin; + float heightMax = prop_heightMax; + float heightBalance = prop_heightBalance; + float heightVariance = prop_heightVariance; + + float balRand = _rand(vec2(_seed) + brickId * vec2(0.01)); + if (balRand > heightBalance) { + return 1.0; + } + + float randVariance = + _rand(vec2(_seed) + (brickId + vec2(1)) * vec2(0.01)); + randVariance *= heightVariance; + + float range = (heightMax - heightMin); + float height = heightMax - range * randVariance; + + return height; + } + + // ===================== brick cell solver ===================== + // localUV: position within the brick's bounding cell (0..1) + // brickId: stable per-brick id used for hashing + // brickDim: relative (width,height) proportions of the brick, + // used to give roundness/bevel the correct aspect ratio + struct CellInfo { + vec2 localUV; + vec2 brickId; + vec2 brickDim; + }; + + // Running Bond (staggered rows) and Stack Bond (no stagger) + CellInfo solveOrthoBond(vec2 uv, bool stagger) + { + vec2 tileSize = vec2(prop_columns, prop_rows); + vec2 pos = uv * tileSize; + + if (stagger) { + float xOffset = 0.0; + if (fract(pos.y * 0.5) > 0.5) { + xOffset = prop_offset; + } + pos.x += xOffset; + } + + vec2 brickId = floor(pos); + + // wrap around x so the hash matches the brick this one + // continues into on the opposite edge + if (brickId.x > tileSize.x - 1.0) + brickId.x = 0.0; + + CellInfo c; + c.localUV = fract(pos); + c.brickId = brickId; + c.brickDim = vec2(prop_brickAspect, 1.0); + return c; + } + + // Herringbone and Basket Weave share an LxL "weave" grid where L is + // the (rounded) brick aspect ratio. Each weave cell is either filled + // with L stacked horizontal bricks or L side-by-side vertical + // bricks, alternating in a checkerboard. Herringbone additionally + // staggers each row/column by its own index, producing the + // characteristic zig-zag. + CellInfo solveWeave(vec2 uv, bool herringbone) + { + vec2 tileSize = vec2(prop_columns, prop_rows); + vec2 pos = uv * tileSize; + + float L = max(1.0, floor(prop_brickAspect + 0.5)); + + vec2 weaveId = floor(pos / L); + vec2 q = pos - weaveId * L; + + bool vertical = mod(weaveId.x + weaveId.y, 2.0) > 0.5; + float stagger = herringbone ? 1.0 : 0.0; + + CellInfo c; + + if (!vertical) { + // L horizontal (L x 1) bricks stacked vertically + float row = floor(q.y); + float shiftedX = mod(pos.x + row * stagger, L); + + c.localUV = vec2(shiftedX / L, fract(pos.y)); + c.brickId = + vec2(floor((pos.x + row * stagger) / L), floor(pos.y)); + c.brickDim = vec2(L, 1.0); + } + else { + // L vertical (1 x L) bricks side by side + float col = floor(q.x); + float shiftedY = mod(pos.y + col * stagger, L); + + c.localUV = vec2(fract(pos.x), shiftedY / L); + c.brickId = + vec2(floor(pos.x), floor((pos.y + col * stagger) / L)); + c.brickDim = vec2(1.0, L); + } + + return c; + } + + // ===================== shape ===================== + // per-edge jitter amounts in [-1,1]: x=left, y=right, z=bottom, w=top + vec4 brickEdgeJitter(vec2 brickId) + { + vec4 j; + j.x = _rand(vec2(_seed) + brickId * vec2(0.0123) + vec2(11.0, 3.0)); + j.y = _rand(vec2(_seed) + brickId * vec2(0.0123) + vec2(23.0, 7.0)); + j.z = + _rand(vec2(_seed) + brickId * vec2(0.0123) + vec2(37.0, 17.0)); + j.w = + _rand(vec2(_seed) + brickId * vec2(0.0123) + vec2(51.0, 29.0)); + return j * 2.0 - 1.0; + } + + // rounded box SDF (centered at origin, half-extents b, corner radius r) + float sdRoundBox(vec2 p, vec2 b, float r) + { + vec2 q = abs(p) - b + r; + return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - r; + } + + vec4 process(vec2 uv) + { + CellInfo c; + if (prop_pattern == 0) + c = solveOrthoBond(uv, true); // Running Bond + else if (prop_pattern == 1) + c = solveOrthoBond(uv, false); // Stack Bond + else if (prop_pattern == 2) + c = solveWeave(uv, true); // Herringbone + else + c = solveWeave(uv, false); // Basket Weave + + // mortar width: uniform + optional per-pixel map + float mortarW = prop_mortarWidth; + if (mortarMap_connected) { + float m = texture(mortarMap, uv).r; + mortarW += (m - 0.5) * prop_mortarMapStrength; + } + mortarW = clamp(mortarW, 0.0, 0.45); + + // non-uniform brick shapes: jitter each edge independently, + // capped so edges never cross into the mortar of the + // neighboring brick + vec4 jitter = brickEdgeJitter(c.brickId); + float jitterAmount = prop_shapeVariance * mortarW * 0.9; + + float left = mortarW + jitter.x * jitterAmount; + float right = mortarW + jitter.y * jitterAmount; + float bottom = mortarW + jitter.z * jitterAmount; + float top = mortarW + jitter.w * jitterAmount; + + vec2 boxMin = vec2(left, bottom); + vec2 boxMax = vec2(1.0 - right, 1.0 - top); + vec2 center = (boxMin + boxMax) * 0.5; + vec2 halfExtent = (boxMax - boxMin) * 0.5; + + // scale into the brick's own proportions so roundness/bevel + // are relative to the brick shape, not the grid cell + vec2 p = (c.localUV - center) * c.brickDim; + vec2 b = halfExtent * c.brickDim; + + float shortSide = 2.0 * min(b.x, b.y); + + float radius = clamp(prop_roundness, 0.0, 1.0) * 0.5 * shortSide; + radius = min(radius, min(b.x, b.y)); + + float sdf = sdRoundBox(p, b, radius); + + float mask = sdf <= 0.0 ? 1.0 : 0.0; + + // bevel: darken a band along the inside of each brick edge + float bevelAmt = prop_bevelAmount; + if (bevelMap_connected) { + float bm = texture(bevelMap, uv).r; + bevelAmt *= + mix(1.0, bm, clamp(prop_bevelMapStrength, 0.0, 1.0)); + } + bevelAmt = clamp(bevelAmt, 0.0, 1.0); + + float bevelWidth = bevelAmt * 0.5 * shortSide; + float bevelMix = 1.0; + if (bevelWidth > 0.0001) { + bevelMix = clamp(-sdf / bevelWidth, 0.0, 1.0); + } + + const float bevelFloor = 0.5; + float bevelMultiplier = mix(bevelFloor, 1.0, bevelMix); + + float height = calculateHeight(c.brickId); + + float finalHeight = mask * height * bevelMultiplier; + + return vec4(vec3(finalHeight), 1.0); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/cellv3.cpp b/src/texturelab/libraries/v3/cellv3.cpp new file mode 100644 index 00000000..61efd978 --- /dev/null +++ b/src/texturelab/libraries/v3/cellv3.cpp @@ -0,0 +1,47 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// Voronoi F1 cell node with independent X/Y scale. +// Seamless: grid sizes are rounded to integers so mod()-wrapped cell hashes +// produce identical values at opposite UV boundaries. +void CellV3Node::init() +{ + this->title = "Cell"; + + this->addFloatProp("scale", "Scale", 30.0, 1.0, 256.0, 1.0); + this->addFloatProp("scaleX", "Scale X", 1.0, 0.1, 10.0, 0.1); + this->addFloatProp("scaleY", "Scale Y", 1.0, 0.1, 10.0, 0.1); + this->addBoolProp ("invert", "Invert", false); + this->addFloatProp("entropy", "Order", 0.0, 0.0, 1.0, 0.01); + this->addFloatProp("intensity", "Intensity", 1.0, 0.0, 2.0, 0.01); + + this->setShaderSource(R""""( + vec4 process(vec2 uv) + { + float gsX = max(1.0, floor(prop_scale * prop_scaleX + 0.5)); + float gsY = max(1.0, floor(prop_scale * prop_scaleY + 0.5)); + + vec2 scaledUV = vec2(uv.x * gsX, uv.y * gsY); + vec2 i_st = floor(scaledUV); + vec2 f_st = fract(scaledUV); + + vec2 seedOff = vec2(_seed * 0.173, _seed * 0.251); + float m_dist = 1.0; + + for (int y = -1; y <= 1; y++) { + for (int x = -1; x <= 1; x++) { + vec2 neighbor = vec2(float(x), float(y)); + vec2 wrapped = mod(i_st + neighbor, vec2(gsX, gsY)); + vec2 point = hash22(wrapped + seedOff); + point = mix(point, vec2(0.5), prop_entropy); + vec2 diff = neighbor + point - f_st; + m_dist = min(m_dist, length(diff)); + } + } + + if (prop_invert) m_dist = 1.0 - m_dist; + return vec4(vec3(m_dist) * prop_intensity, 1.0); + } + )""""); +} diff --git a/src/texturelab/libraries/v3/colortomask.cpp b/src/texturelab/libraries/v3/colortomask.cpp new file mode 100644 index 00000000..cc468658 --- /dev/null +++ b/src/texturelab/libraries/v3/colortomask.cpp @@ -0,0 +1,70 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// https://en.wikipedia.org/wiki/HSL_and_HSV +// Converts a target colour range in an input image to a greyscale mask. +// Uses HSL-space distance for perceptually accurate colour matching — far +// more reliable than RGB Euclidean distance. +// Primary use: extract zones from a flat-colour ID map to drive per-material +// Height Blend, roughness or metalness branches. +void ColorToMaskNode::init() +{ + this->title = "Color To Mask"; + + this->addInput("image"); + + this->addColorProp("target_color", "Target Color", QColor(255, 0, 0)); + this->addFloatProp("hue_range", "Hue Range", 1.0, 0.1, 4.0, 0.1); + this->addFloatProp("sat_range", "Sat Range", 0.5, 0.0, 4.0, 0.1); + this->addFloatProp("lum_range", "Lum Range", 0.3, 0.0, 4.0, 0.1); + this->addFloatProp("softness", "Softness", 0.2, 0.01, 1.0, 0.01); + this->addBoolProp ("invert", "Invert", false); + + auto source = R""""( + vec3 rgb2hsl(vec3 c) { + float maxC = max(c.r, max(c.g, c.b)); + float minC = min(c.r, min(c.g, c.b)); + float delta = maxC - minC; + + float h = 0.0; + if (delta > 0.001) { + if (maxC == c.r) h = mod((c.g - c.b) / delta, 6.0); + else if (maxC == c.g) h = (c.b - c.r) / delta + 2.0; + else h = (c.r - c.g) / delta + 4.0; + h /= 6.0; + if (h < 0.0) h += 1.0; + } + float l = (maxC + minC) * 0.5; + float s = (delta < 0.001) ? 0.0 + : delta / (1.0 - abs(2.0 * l - 1.0)); + return vec3(h, s, l); + } + + vec4 process(vec2 uv) + { + if (!image_connected) + return vec4(0.0, 0.0, 0.0, 1.0); + + vec3 col = texture(image, uv).rgb; + vec3 hsl = rgb2hsl(col); + vec3 targetHSL = rgb2hsl(prop_target_color.rgb); + + // Hue distance is circular + float hueDist = abs(hsl.x - targetHSL.x); + hueDist = min(hueDist, 1.0 - hueDist); + + float dist = hueDist * prop_hue_range + + abs(hsl.y - targetHSL.y) * prop_sat_range + + abs(hsl.z - targetHSL.z) * prop_lum_range; + + float mask = 1.0 - smoothstep(0.0, prop_softness, dist); + + if (prop_invert) mask = 1.0 - mask; + + return vec4(vec3(clamp(mask, 0.0, 1.0)), 1.0); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/curvature.cpp b/src/texturelab/libraries/v3/curvature.cpp new file mode 100644 index 00000000..5a872671 --- /dev/null +++ b/src/texturelab/libraries/v3/curvature.cpp @@ -0,0 +1,97 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +void CurvatureNode::init() +{ + this->title = "Curvature"; + this->addInput("height"); + + auto typeProp = this->addEnumProp("type", "Type", + {"Mix", "Sharp", "Medium", "Smooth"}); + typeProp->index = 3; + + this->addFloatProp("intensity", "Intensity", 1.0, 0.0, 2.0, 0.1); + this->addFloatProp("angle", "Angle", 0.5, -1.0, 1.0, 0.1); + this->addIntProp("samples", "Samples", 12, 1, 32, 1); + + auto advancedProps = this->createGroup("Advanced"); + advancedProps->add( + this->addFloatProp("sh_c", "Sharp Curvature", 0.5, 0.0, 1.0, 0.1)); + advancedProps->add( + this->addFloatProp("me_c", "Medium Curvature", 1.5, 0.0, 3.0, 0.1)); + advancedProps->add( + this->addFloatProp("sm_c", "Smooth Curvature", 3.0, 0.0, 4.0, 0.1)); + + auto source = R""""( + #define LAPLACIAN_CENTER_WEIGHT 2.0 + #define RADIUS_SCALE 0.01 + #define SHARP_WEIGHT 8.0 + #define MEDIUM_WEIGHT 3.0 + #define SMOOTH_WEIGHT 1.5 + + #define TYPE_MIX 0 + #define TYPE_SHARP 1 + #define TYPE_MEDIUM 2 + #define TYPE_SMOOTH 3 + + float HeightMap(vec2 p) + { + return texture(height, p).x; + } + + float _curveSample(vec2 p, vec2 o) + { + float a = HeightMap(p + o); + float b = HeightMap(p - o); + return -a - b; + } + + float CurvatureMap(vec2 p, float r) + { + float q = float(prop_samples); + float s = r / q; + float H = HeightMap(p) * LAPLACIAN_CENTER_WEIGHT; + float v = 0.0; + + for (float ox = -q; ox < q; ox++) + for (float oy = -q; oy < q; oy++) + { + vec2 o = vec2(ox, oy); + float c = _curveSample(p, o * s); + v += (H + c) * ((r - length(o * s)) / r); + } + + return v / (q * q); + } + + vec4 process(vec2 uv) + { + if (!height_connected) + return vec4(0.0, 0.0, 0.0, 1.0); + + float i = prop_intensity; + float c = 0.0; + + if (prop_type == TYPE_MIX) { + c += CurvatureMap(uv, i * prop_sh_c * RADIUS_SCALE) * SHARP_WEIGHT; + c += CurvatureMap(uv, i * prop_me_c * RADIUS_SCALE) * MEDIUM_WEIGHT; + c += CurvatureMap(uv, i * prop_sm_c * RADIUS_SCALE) * SMOOTH_WEIGHT; + } else if (prop_type == TYPE_SHARP) { + c += CurvatureMap(uv, i * prop_sh_c * RADIUS_SCALE) * SHARP_WEIGHT; + } else if (prop_type == TYPE_MEDIUM) { + c += CurvatureMap(uv, i * prop_me_c * RADIUS_SCALE) * MEDIUM_WEIGHT; + } else if (prop_type == TYPE_SMOOTH) { + c += CurvatureMap(uv, i * prop_sm_c * RADIUS_SCALE) * SMOOTH_WEIGHT; + } + + vec4 color; + color.rgb = vec3(prop_angle + c); + color.a = 1.0; + + return color; + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/curvenode.cpp b/src/texturelab/libraries/v3/curvenode.cpp new file mode 100644 index 00000000..c12e4824 --- /dev/null +++ b/src/texturelab/libraries/v3/curvenode.cpp @@ -0,0 +1,20 @@ +#include "curvenode.h" + +void CurveNode::init() +{ + this->title = "Curve"; + this->addInput("image"); + this->addCurveProp("curve", "Curve"); + + auto source = R""""( + vec4 process(vec2 uv) { + vec4 col = texture(image, uv); + float gray = (col.r + col.g + col.b) * 0.3333333; + float mapped = evalCurve(prop_curve, gray); + return vec4(vec3(mapped), col.a); + // return vec4(1.0,0.0,0.0,1.0); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/curvenode.h b/src/texturelab/libraries/v3/curvenode.h new file mode 100644 index 00000000..639f1763 --- /dev/null +++ b/src/texturelab/libraries/v3/curvenode.h @@ -0,0 +1,8 @@ +#pragma once + +#include "../../models.h" + +class CurveNode : public TextureNode { +public: + void init() override; +}; diff --git a/src/texturelab/libraries/v3/directionalscratches.cpp b/src/texturelab/libraries/v3/directionalscratches.cpp new file mode 100644 index 00000000..bad2e8a0 --- /dev/null +++ b/src/texturelab/libraries/v3/directionalscratches.cpp @@ -0,0 +1,124 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// Procedural directional scratch / brushed-metal generator. +// Divides the rotated UV space into N horizontal bands; each band has a +// chance to contain ONE scratch with random start position, length, vertical +// offset, thickness and brightness. +// +// Seamless tiling: angle is restricted to a stepped enum of geometrically +// tileable orientations (0°, 45°, 90°, 135°). Each option uses an +// integer-coefficient rotation matrix so that a UV-tile shift translates to +// an integer band-index shift; combined with mod()-wrapped band indices and +// fract()-wrapped scratch coordinates, the pattern tiles perfectly. +void DirectionalScratchesNode::init() +{ + this->title = "Directional Scratches"; + + auto angleProp = this->addEnumProp("angle", "Angle", + {"0° (Horizontal)", "45°", "90° (Vertical)", "135°"}); + angleProp->index = 0; + + this->addIntProp ("count", "Count", 80, 10, 500, 5); + this->addFloatProp("density", "Density", 0.5, 0.0, 1.0, 0.01); + this->addFloatProp("length", "Length", 0.6, 0.05, 1.0, 0.01); + this->addFloatProp("length_var", "Length Variance", 0.5, 0.0, 1.0, 0.05); + this->addFloatProp("width", "Width", 0.3, 0.05, 1.0, 0.01); + this->addFloatProp("waviness", "Waviness", 0.0, 0.0, 1.0, 0.01); + this->addFloatProp("intensity", "Intensity", 1.0, 0.0, 2.0, 0.05); + this->addIntProp ("layers", "Layers", 1, 1, 4, 1); + + auto source = R""""( + #define PI 3.14159265358979 + + #define ANGLE_0 0 + #define ANGLE_45 1 + #define ANGLE_90 2 + #define ANGLE_135 3 + + // Hash with project-wide seed offset + float scrHash(vec2 p) { + return hash12(p + vec2(_seed * 0.131, _seed * 0.379)); + } + + // Integer-coefficient rotation: preserves the property that a UV + // shift of (1,0) or (0,1) produces an integer rotated-coordinate + // shift, which is what makes the pattern tile perfectly. + // Diagonal angles (45°/135°) come out scaled by √2 — this just means + // diagonal patterns appear √2 denser per UV tile than cardinal ones. + vec2 rotateUV(vec2 uv) { + if (prop_angle == ANGLE_0) return uv; + if (prop_angle == ANGLE_45) return vec2(uv.x + uv.y, uv.y - uv.x); + if (prop_angle == ANGLE_90) return vec2(uv.y, -uv.x); + /* ANGLE_135 */ return vec2(uv.y - uv.x, -uv.x - uv.y); + } + + // One layer of discrete scratch line segments. + // Each band gets at most one scratch with random properties. + float scratchLayer(vec2 uv, float layerIdx) { + vec2 rot = rotateUV(uv); + + float bandCount = float(prop_count); + + // mod() wraps band index to [0, bandCount-1] so band hashes match + // across UV-tile boundaries. Integer-rotation guarantees that + // floor(rot.y * bandCount) shifts by exactly bandCount across one + // UV tile, so the mod is exact. + float band = mod(floor(rot.y * bandCount), bandCount); + float bandFract = fract(rot.y * bandCount); + + // Per-band deterministic random properties + vec2 seed = vec2(band, layerIdx * 113.7); + float rExist = scrHash(seed + vec2(0.11, 0.23)); + float rStartX = scrHash(seed + vec2(0.37, 0.59)); + float rLength = scrHash(seed + vec2(0.71, 0.97)); + float rOffset = scrHash(seed + vec2(1.13, 1.31)); + float rThickness = scrHash(seed + vec2(1.51, 1.79)); + float rIntensity = scrHash(seed + vec2(1.97, 2.13)); + + // Sparsity: only a fraction of bands actually contain a scratch + if (rExist > prop_density) return 0.0; + + // Scratch length, with optional variance pulling shorter + float lenFactor = mix(1.0, rLength, prop_length_var); + float scratchLen = clamp(prop_length * lenFactor, 0.02, 1.0); + + // X-position along the scratch — fract handles tiling automatically + float xLocal = fract(rot.x - rStartX); + if (xLocal > scratchLen) return 0.0; + + // Soft fade at both ends, proportional to length + float fade = min(0.04, scratchLen * 0.25); + float xMask = smoothstep(0.0, fade, xLocal) + * (1.0 - smoothstep(scratchLen - fade, scratchLen, xLocal)); + + // Optional gentle waviness along the scratch + float wave = sin(xLocal * 28.0 + band * 13.7) + * prop_waviness * 0.15; + + // Vertical position within the band + float lineCenter = 0.5 + (rOffset - 0.5) * 0.3 + wave; + + // Line profile across band; width is fraction of band height + float dist = abs(bandFract - lineCenter); + float halfW = prop_width * 0.25 * mix(0.6, 1.0, rThickness); + float line = 1.0 - smoothstep(halfW * 0.5, halfW, dist); + + // Per-scratch brightness variation + return line * xMask * mix(0.4, 1.0, rIntensity); + } + + vec4 process(vec2 uv) + { + float result = 0.0; + for (int i = 0; i < prop_layers; i++) { + // Each layer generates an independent scratch set via the hash + result = max(result, scratchLayer(uv, float(i) + 1.0)); + } + return vec4(vec3(clamp(result * prop_intensity, 0.0, 1.0)), 1.0); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/distancetransform.cpp b/src/texturelab/libraries/v3/distancetransform.cpp new file mode 100644 index 00000000..0a6f753f --- /dev/null +++ b/src/texturelab/libraries/v3/distancetransform.cpp @@ -0,0 +1,234 @@ +#include "../../graphics/noderenderer.h" +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +#include +#include +#include + +// Rong & Tan, "Jump Flooding in GPU with Applications to Voronoi Diagram and +// Distance Transform," I3D 2006 +// https://bgolus.medium.com/the-quest-for-very-wide-outlines-ba82ed442cd9 +// (Ben Golus — GPU distance transforms, JFA accuracy analysis) +// +// Jump Flooding Algorithm (JFA) Euclidean distance transform. +// Each foreground pixel in the thresholded mask seeds the JFA; subsequent +// passes propagate nearest-seed UVs across the texture in O(log N) passes. +// The final pass converts nearest-seed UV to a normalised distance value. +// +// Reuses the same JFA infrastructure as BevelV2; this node exposes the raw +// distance field rather than a bevel profile, making it a universal primitive +// for soft edge halos, wear gradients and stencil masks. + +// ============================================================================ +// DistanceTransformRenderData +// ============================================================================ +struct DistanceTransformRenderData : public NodeRenderData { + float threshold = 0.5f; + float spread = 0.5f; + bool invert = false; +}; + +// ============================================================================ +// DistanceTransformRenderer +// ============================================================================ +class DistanceTransformRenderer : public NodeTextureRenderer { +public: + void render(NodeRenderContext& ctx, const NodeRenderData& baseData) override + { + auto& data = static_cast(baseData); + auto gl = ctx.gl; + auto cache = ctx.cache; + int w = ctx.textureWidth; + int h = ctx.textureHeight; + + if (ctx.inputs.isEmpty() || ctx.inputs[0].textureId == 0) { + cache->bindFboToTexture(ctx.outputTextureId); + gl->glViewport(0, 0, w, h); + gl->glClearColor(0, 0, 0, 1); + gl->glClear(GL_COLOR_BUFFER_BIT); + return; + } + + GLuint seedShader = cache->getOrCompileShader( + "jfadt_seed", RenderResourceCache::standardVertexSource(), seedFrag()); + GLuint jfaShader = cache->getOrCompileShader( + "jfadt_step", RenderResourceCache::standardVertexSource(), jfaFrag()); + GLuint distShader = cache->getOrCompileShader( + "jfadt_dist", RenderResourceCache::standardVertexSource(), distFrag()); + + GLuint texA = cache->acquireTexture(w, h); + GLuint texB = cache->acquireTexture(w, h); + + // --- Seed pass: foreground pixels write their own UV; background writes (-1,-1) --- + cache->bindFboToTexture(texA); + ctx.useShader(seedShader); + ctx.bindTexture(seedShader, "u_mask", ctx.inputs[0].textureId, 0); + gl->glUniform1f( + gl->glGetUniformLocation(seedShader, "u_threshold"), + data.threshold); + ctx.drawQuad(); + + // --- JFA passes --- + int maxDim = std::max(w, h); + int stepSize = maxDim / 2; + while (stepSize >= 1) { + cache->bindFboToTexture(texB); + ctx.useShader(jfaShader); + ctx.bindTexture(jfaShader, "u_input", texA, 0); + gl->glUniform1i( + gl->glGetUniformLocation(jfaShader, "u_step"), stepSize); + ctx.drawQuad(); + std::swap(texA, texB); + stepSize /= 2; + } + + // --- Distance pass: convert nearest-seed UV to normalised distance --- + cache->bindFboToTexture(ctx.outputTextureId); + ctx.useShader(distShader); + ctx.bindTexture(distShader, "u_jfa", texA, 0); + gl->glUniform1f( + gl->glGetUniformLocation(distShader, "u_spread"), + data.spread); + gl->glUniform1i( + gl->glGetUniformLocation(distShader, "u_invert"), + data.invert ? 1 : 0); + ctx.drawQuad(); + + cache->releaseTexture(texA); + cache->releaseTexture(texB); + } + +private: + // Seed pass: store UV in RG if foreground, sentinel (-1,-1) if background + static QString seedFrag() + { + return R""""( + #version 150 + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_mask; + uniform float u_threshold; + + void main() + { + float v = texture(u_mask, v_texCoord).r; + if (v >= u_threshold) + fragColor = vec4(v_texCoord, 0.0, 1.0); // seed: own UV + else + fragColor = vec4(-1.0, -1.0, 0.0, 1.0); // no seed + } + )""""; + } + + // JFA step: for each pixel, sample 8 neighbours at ±stepSize and keep + // whichever carries the nearest valid seed UV + static QString jfaFrag() + { + return R""""( + #version 150 + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_input; + uniform int u_step; + + void main() + { + vec2 texSize = vec2(textureSize(u_input, 0)); + vec2 step = vec2(float(u_step)) / texSize; + + vec2 bestUV = vec2(-1.0); + float bestDist = 1e9; + + for (int x = -1; x <= 1; x++) { + for (int y = -1; y <= 1; y++) { + vec2 sampleUV = v_texCoord + vec2(float(x), float(y)) * step; + vec4 s = texture(u_input, sampleUV); + vec2 seedUV = s.rg; + if (seedUV.x < 0.0) continue; // no seed stored here + float d = length(v_texCoord - seedUV); + if (d < bestDist) { + bestDist = d; + bestUV = seedUV; + } + } + } + + fragColor = vec4(bestUV, 0.0, 1.0); + } + )""""; + } + + // Distance pass: convert nearest-seed UV to a normalised, spread-scaled + // grayscale value + static QString distFrag() + { + return R""""( + #version 150 + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_jfa; + uniform float u_spread; + uniform int u_invert; + + void main() + { + vec2 seedUV = texture(u_jfa, v_texCoord).rg; + + float dist; + if (seedUV.x < 0.0) { + // No seed found — maximum distance + dist = 1.0; + } else { + // Raw distance in [0,~1.41] UV space + dist = length(v_texCoord - seedUV); + // Scale by spread: spread=0.5 → moderate falloff + float invSpread = 1.0 / max(u_spread, 0.001); + dist = clamp(dist * invSpread * 2.0, 0.0, 1.0); + } + + if (u_invert == 0) + dist = 1.0 - dist; // bright near seed, dark far away (default) + + fragColor = vec4(vec3(dist), 1.0); + } + )""""; + } +}; + +// ============================================================================ +// DistanceTransformNode +// ============================================================================ +void DistanceTransformNode::init() +{ + this->title = "Distance Transform"; + + this->addInput("mask"); + + this->addFloatProp("threshold", "Threshold", 0.5, 0.0, 1.0, 0.01); + this->addFloatProp("spread", "Spread", 0.5, 0.0, 1.0, 0.01); + this->addBoolProp ("invert", "Invert", false); +} + +std::shared_ptr DistanceTransformNode::createRenderer() +{ + return std::make_shared(); +} + +std::shared_ptr DistanceTransformNode::createRenderData() +{ + auto data = std::make_shared(); + + if (auto* p = dynamic_cast(getProp("threshold"))) + data->threshold = static_cast(p->value); + if (auto* p = dynamic_cast(getProp("spread"))) + data->spread = static_cast(p->value); + if (auto* p = dynamic_cast(getProp("invert"))) + data->invert = p->value; + + return data; +} diff --git a/src/texturelab/libraries/v3/edgedetect.cpp b/src/texturelab/libraries/v3/edgedetect.cpp new file mode 100644 index 00000000..0987ed68 --- /dev/null +++ b/src/texturelab/libraries/v3/edgedetect.cpp @@ -0,0 +1,82 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// https://en.wikipedia.org/wiki/Sobel_operator +// https://docs.opencv.org/4.x/d2/d2c/tutorial_sobel_derivatives.html +void EdgeDetectNode::init() +{ + this->title = "Edge Detect"; + + this->addInput("image"); + + auto kernelProp = this->addEnumProp("kernel", "Kernel", + {"Sobel", "Prewitt", "Laplacian"}); + kernelProp->index = 0; + + this->addFloatProp("intensity", "Intensity", 1.0, 0.0, 4.0, 0.1); + this->addFloatProp("threshold", "Threshold", 0.0, 0.0, 1.0, 0.01); + this->addFloatProp("width", "Width", 1.0, 0.5, 8.0, 0.5); + this->addBoolProp ("invert", "Invert", false); + + auto source = R""""( + #define KERNEL_SOBEL 0 + #define KERNEL_PREWITT 1 + #define KERNEL_LAPLACIAN 2 + + float luminance(vec3 c) { + return dot(c, vec3(0.2126, 0.7152, 0.0722)); + } + + float sampleLum(vec2 uv) { + return luminance(texture(image, uv).rgb); + } + + vec4 process(vec2 uv) + { + if (!image_connected) + return vec4(0.0, 0.0, 0.0, 1.0); + + vec2 step = (prop_width / _textureSize.xy); + + float tl = sampleLum(uv + vec2(-step.x, step.y)); + float t = sampleLum(uv + vec2( 0.0, step.y)); + float tr = sampleLum(uv + vec2( step.x, step.y)); + float l = sampleLum(uv + vec2(-step.x, 0.0)); + float r = sampleLum(uv + vec2( step.x, 0.0)); + float bl = sampleLum(uv + vec2(-step.x, -step.y)); + float b = sampleLum(uv + vec2( 0.0, -step.y)); + float br = sampleLum(uv + vec2( step.x, -step.y)); + + float gx = 0.0; + float gy = 0.0; + float edge = 0.0; + + if (prop_kernel == KERNEL_SOBEL) { + gx = -tl - 2.0*l - bl + tr + 2.0*r + br; + gy = -tl - 2.0*t - tr + bl + 2.0*b + br; + edge = length(vec2(gx, gy)); + } else if (prop_kernel == KERNEL_PREWITT) { + gx = -tl - l - bl + tr + r + br; + gy = -tl - t - tr + bl + b + br; + edge = length(vec2(gx, gy)); + } else { + // Laplacian: [-1,-1,-1; -1,8,-1; -1,-1,-1] × center + float center = sampleLum(uv); + edge = abs(8.0 * center - (tl + t + tr + l + r + bl + b + br)); + } + + edge *= prop_intensity; + edge = max(0.0, edge - prop_threshold); + + if (prop_invert) + edge = 1.0 - clamp(edge, 0.0, 1.0); + else + edge = clamp(edge, 0.0, 1.0); + + return vec4(vec3(edge), 1.0); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/emboss.cpp b/src/texturelab/libraries/v3/emboss.cpp new file mode 100644 index 00000000..3386ca9c --- /dev/null +++ b/src/texturelab/libraries/v3/emboss.cpp @@ -0,0 +1,66 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// https://en.wikipedia.org/wiki/Emboss_(photography) +// Fake 2D directional light across a height-map surface. +// Distinct from Normal Map: outputs a shaded image, not a vector map. +// Use as a quick height-preview or for stylised cel-shading effects. +void EmbossNode::init() +{ + this->title = "Emboss"; + + this->addInput("image"); + + this->addFloatProp("angle", "Light Angle", 45.0, 0.0, 360.0, 1.0); + this->addFloatProp("elevation", "Light Elevation", 0.5, 0.0, 1.0, 0.05); + this->addFloatProp("intensity", "Intensity", 3.0, 0.1, 16.0, 0.1); + this->addBoolProp ("invert", "Invert Height", false); + + auto source = R""""( + #define PI 3.14159265358979 + + float sampleH(vec2 uv) { + float h = texture(image, uv).r; + return prop_invert ? 1.0 - h : h; + } + + vec4 process(vec2 uv) + { + if (!image_connected) + return vec4(0.5, 0.5, 0.5, 1.0); + + float angleRad = prop_angle * PI / 180.0; + vec2 texel = 1.0 / _textureSize.xy; + + // Central-difference gradient + float hL = sampleH(uv - vec2(texel.x, 0.0)); + float hR = sampleH(uv + vec2(texel.x, 0.0)); + float hD = sampleH(uv - vec2(0.0, texel.y)); + float hU = sampleH(uv + vec2(0.0, texel.y)); + + // Build surface normal from finite differences + // intensity scales how steeply height maps to angle + vec3 normal = normalize(vec3( + (hL - hR) * prop_intensity, + (hD - hU) * prop_intensity, + 1.0 + )); + + // Light direction from angle and elevation + float elev = prop_elevation * PI * 0.5; // [0..PI/2] + vec3 lightDir = normalize(vec3( + cos(angleRad) * cos(elev), + sin(angleRad) * cos(elev), + sin(elev) + )); + + float diffuse = dot(normal, lightDir); + float result = diffuse * 0.5 + 0.5; // remap [-1,1] → [0,1] + + return vec4(vec3(clamp(result, 0.0, 1.0)), 1.0); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/fbmdomainwarp.cpp b/src/texturelab/libraries/v3/fbmdomainwarp.cpp new file mode 100644 index 00000000..fb1926b4 --- /dev/null +++ b/src/texturelab/libraries/v3/fbmdomainwarp.cpp @@ -0,0 +1,96 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// https://iquilezles.org/articles/warp/ +// Quilez, "Domain Warping" — the definitive reference with interactive demos. +// +// fBm where the sampling coordinates are themselves offset by another layer of +// fBm, producing swirling organic turbulence that is impossible to achieve +// with standard layered noise. Warp 1 creates mild swirling; enabling +// Double Warp adds a second recursive step for maximum complexity. +// +// Seamless tiling: the base scale is an integer grid size (cells per UV +// tile), each octave doubles it, and value-noise hashes use mod()-wrapped +// cell indices. Because the warp itself is also seamless (its inputs share +// the same wrapping), warped sampling positions differ by integer amounts +// across the texture boundary — preserving the periodic property end-to-end. +void FBMDomainWarpNode::init() +{ + this->title = "FBM Domain Warp"; + + this->addIntProp ("scale", "Scale", 3, 1, 12, 1); + this->addIntProp ("octaves", "Octaves", 6, 1, 8, 1); + this->addFloatProp("warp", "Primary Warp", 1.0, 0.0, 4.0, 0.1); + this->addFloatProp("warp2", "Secondary Warp", 0.5, 0.0, 4.0, 0.1); + this->addBoolProp ("two_pass", "Double Warp", true); + + auto source = R""""( + // Tileable value noise: cell hashes use mod()-wrapped indices so the + // noise repeats exactly every gridSize cells along each axis. + float vnoise(vec2 sampleUV, float gridSize) { + vec2 i = floor(sampleUV); + vec2 f = fract(sampleUV); + vec2 u = f * f * (3.0 - 2.0 * f); + + vec2 sOff = vec2(_seed * 0.173); + + float a = hash12(mod(i + vec2(0.0, 0.0), vec2(gridSize)) + sOff); + float b = hash12(mod(i + vec2(1.0, 0.0), vec2(gridSize)) + sOff); + float c = hash12(mod(i + vec2(0.0, 1.0), vec2(gridSize)) + sOff); + float d = hash12(mod(i + vec2(1.0, 1.0), vec2(gridSize)) + sOff); + + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); + } + + // Multi-octave fBm. Each octave doubles the grid size (lacunarity 2); + // since baseScale is integer, every octave's grid size remains integer + // → mod() wrapping is exact and the entire fBm is seamlessly tileable. + float fbm(vec2 uv) { + float value = 0.0; + float amp = 0.5; + float gridSize = float(prop_scale); + for (int i = 0; i < prop_octaves; i++) { + value += amp * vnoise(uv * gridSize, gridSize); + gridSize *= 2.0; + amp *= 0.5; + } + return value; + } + + vec4 process(vec2 uv) + { + // Warp pass 1: offset UV by two independent fBm samples. + // The constant offsets (5.2, 1.3) etc. just shift which part of + // the noise we read; they don't affect tileability because mod() + // wrapping handles any sample position. + vec2 q = vec2( + fbm(uv + vec2(0.000, 0.000)), + fbm(uv + vec2(5.200, 1.300)) + ); + + float f; + if (prop_two_pass) { + // Warp pass 2: use q to offset again (recursive swirling). + // Since q is itself seamless, q at uv=0 equals q at uv=1, + // so the warped sampling positions still differ by exactly 1 + // across the tile boundary — fbm's mod() wrap maps them to + // the same noise value. + vec2 r = vec2( + fbm(uv + prop_warp * q + vec2(1.700, 9.200)), + fbm(uv + prop_warp * q + vec2(8.300, 2.800)) + ); + f = fbm(uv + prop_warp2 * r); + } else { + f = fbm(uv + prop_warp * q); + } + + // Remap to [0,1] and add a slight contrast lift + f = clamp(f * 1.4 - 0.1, 0.0, 1.0); + + return vec4(vec3(f), 1.0); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/floodfillv2.cpp b/src/texturelab/libraries/v3/floodfillv2.cpp new file mode 100644 index 00000000..e7313644 --- /dev/null +++ b/src/texturelab/libraries/v3/floodfillv2.cpp @@ -0,0 +1,209 @@ +#include "../../graphics/noderenderer.h" +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +#include +#include +#include +#include +#include +#include + +// ============================================================================ +// FloodFillV2RenderData +// ============================================================================ + +struct FloodFillV2RenderData : public NodeRenderData { + float threshold = 0.1f; +}; + +// ============================================================================ +// FloodFillV2Renderer — CPU BFS flood fill via NodeTextureRenderer +// +// Output encoding (RGBA32F per pixel): +// R = island origin x (UV, wrapped to [0,1], quantized to texel center) +// G = island origin y (same) +// B = bbox width / texture width +// A = bbox height / texture height +// Background = (0, 0, 0, 0) +// +// The origin is stored directly — downstream nodes use it as a per-island +// identity without reconstruction arithmetic, eliminating precision issues. +// UV-within-bbox is derived by downstream nodes as (uv - origin) / bbox_size. +// ============================================================================ + +class FloodFillV2Renderer : public NodeTextureRenderer { +public: + void render(NodeRenderContext& ctx, + const NodeRenderData& baseData) override + { + auto& data = static_cast(baseData); + auto gl = ctx.gl; + auto cache = ctx.cache; + + int w = ctx.textureWidth; + int h = ctx.textureHeight; + + // No input — output black + if (ctx.inputs.isEmpty() || ctx.inputs[0].textureId == 0) { + cache->bindFboToTexture(ctx.outputTextureId); + gl->glViewport(0, 0, w, h); + gl->glClearColor(0, 0, 0, 1); + gl->glClear(GL_COLOR_BUFFER_BIT); + return; + } + + int gridSize = w * h; + + // Read input pixels via FBO + std::vector readPixels(gridSize * 4); + cache->bindFboToTexture(ctx.inputs[0].textureId); + gl->glReadPixels(0, 0, w, h, GL_RGBA, GL_FLOAT, readPixels.data()); + + // Helper: wrap pixel coordinate into [0, bound) + auto wrapAround = [](int value, int bound) -> int { + return ((value % bound) + bound) % bound; + }; + + // Helper: get pixel intensity (average of RGB) + auto getIntensity = [&](int x, int y) -> float { + int idx = 4 * (w * y + x); + return (readPixels[idx] + readPixels[idx + 1] + readPixels[idx + 2]) + / 3.0f; + }; + + // BFS flood fill with wrap-around + struct Island { + int left = INT_MAX, top = INT_MAX; + int right = INT_MIN, bottom = INT_MIN; + struct Pixel { + int localX, localY, globalX, globalY; + }; + std::vector pixels; + + void expand(int x, int y) + { + left = std::min(left, x); + top = std::min(top, y); + right = std::max(right, x); + bottom = std::max(bottom, y); + } + int width() const { return right - left; } + int height() const { return bottom - top; } + }; + + std::vector visited(gridSize, false); + std::vector islands; + + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + if (visited[y * w + x]) + continue; + + Island island; + std::queue> queue; + queue.push({x, y}); + + while (!queue.empty()) { + auto [gx, gy] = queue.front(); + queue.pop(); + + int lx = wrapAround(gx, w); + int ly = wrapAround(gy, h); + + if (visited[ly * w + lx]) + continue; + visited[ly * w + lx] = true; + + if (getIntensity(lx, ly) < data.threshold) + continue; + + island.expand(gx, gy); + island.pixels.push_back({lx, ly, gx, gy}); + + queue.push({gx + 1, gy}); + queue.push({gx - 1, gy}); + queue.push({gx, gy + 1}); + queue.push({gx, gy - 1}); + } + + if (island.width() > 0 && island.height() > 0) + islands.push_back(std::move(island)); + } + } + + // Build output texture + // Encoding: (origin_x, origin_y, bbox_w, bbox_h) + // origin is the top-left of the bbox, wrapped to [0,1] UV space, + // quantized to texel centers for consistency across all pixels + std::vector results(gridSize * 4, 0.0f); + + float invW = 1.0f / w; + float invH = 1.0f / h; + + for (const auto& island : islands) { + float bboxW = island.width() * invW; + float bboxH = island.height() * invH; + + // Origin in UV space, wrapped to [0,1] and quantized to texel center + int originLocalX = wrapAround(island.left, w); + int originLocalY = wrapAround(island.top, h); + float originU = (originLocalX + 0.5f) * invW; + float originV = (originLocalY + 0.5f) * invH; + + for (const auto& px : island.pixels) { + int idx = 4 * (w * px.localY + px.localX); + results[idx + 0] = originU; + results[idx + 1] = originV; + results[idx + 2] = bboxW; + results[idx + 3] = bboxH; + } + } + + // Upload result to output texture + gl->glBindTexture(GL_TEXTURE_2D, ctx.outputTextureId); + gl->glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA32F, w, h, 0, GL_RGBA, + GL_FLOAT, results.data()); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); + gl->glBindTexture(GL_TEXTURE_2D, 0); + } +}; + +// ============================================================================ +// FloodFillV2Node +// ============================================================================ + +void FloodFillV2Node::init() +{ + this->title = "Flood Fill V2"; + this->addInput("image"); + this->addFloatProp("threshold", "Threshold", 0.1, 0.0, 1.0, 0.01); + + auto source = R""""( + vec4 process(vec2 uv) + { + return texture(image, uv); + } + )""""; + this->setShaderSource(source); +} + +std::shared_ptr FloodFillV2Node::createRenderer() +{ + return std::make_shared(); +} + +std::shared_ptr FloodFillV2Node::createRenderData() +{ + auto data = std::make_shared(); + + auto threshProp = static_cast(this->getProp("threshold")); + if (threshProp) + data->threshold = threshProp->value; + + return data; +} diff --git a/src/texturelab/libraries/v3/floodfillv2sampler.cpp b/src/texturelab/libraries/v3/floodfillv2sampler.cpp new file mode 100644 index 00000000..c4bf6edd --- /dev/null +++ b/src/texturelab/libraries/v3/floodfillv2sampler.cpp @@ -0,0 +1,124 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +#include + +void FloodFillV2SamplerNode::init() +{ + this->title = "FF Sampler V2"; + + this->addInput("floodfill"); + this->addInput("image"); + this->addInput("mask"); + this->addInput("size"); + this->addInput("intensity"); + + this->addFloatProp("rot", "Rotation", 0, 0, 360, 0.1); + this->addFloatProp("rotRand", "Random Rotation", 0, 0, 1.0, 0.01); + this->addFloatProp("posRand", "Random Position", 0, 0, 1.0, 0.01); + this->addFloatProp("intensityRand", "Random Intensity", 0, 0, 1.0, 0.01); + this->addFloatProp("scale", "Scale", 1, 0, 4, 0.1); + this->addFloatProp("scaleRand", "Scale random", 0, 0, 1, 0.1); + + this->addColorProp("bg", "Background Color", QColor()); + + auto source = R""""( + mat3 transMat(vec2 t) + { + return mat3(vec3(1.0,0.0,0.0), vec3(0.0,1.0,0.0), vec3(t, 1.0)); + } + + mat3 scaleMat(vec2 s) + { + return mat3(vec3(s.x,0.0,0.0), vec3(0.0,s.y,0.0), vec3(0.0, 0.0, 1.0)); + } + + mat3 rotMat(float rot) + { + float r = radians(rot); + return mat3(vec3(cos(r), -sin(r),0.0), vec3(sin(r), cos(r),0.0), vec3(0.0, 0.0, 1.0)); + } + + vec2 transformUV(vec2 uv, vec2 translate, float rot, vec2 scale) + { + mat3 trans = transMat(vec2(0.5, 0.5)) * + transMat(vec2(translate.x, translate.y)) * + rotMat(rot) * + scaleMat(vec2(scale.x, scale.y)) * + transMat(vec2(-0.5, -0.5)); + + vec3 res = inverse(trans) * vec3(uv, 1.0); + uv = res.xy; + + return clamp(uv, vec2(0.0), vec2(1.0)); + } + + float randomFloatRange(vec2 seed, int offset, float fmin, float fmax) + { + float r = _rand(vec2(_seed) + seed + vec2(float(offset)) * 0.01); + return fmin + (fmax - fmin) * r; + } + + vec4 sampleImage(sampler2D img, vec2 uv) + { + if (uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0) + return texture(img, uv); + return vec4(prop_bg.rgb, 1.0); + } + + vec4 process(vec2 uv) + { + vec4 data = texture(floodfill, uv); + + if (data.ba == vec2(0.0, 0.0)) + return vec4(prop_bg.rgb, 1.0); + + // Origin and bbox from encoding + vec2 origin = data.rg; + vec2 bboxSize = data.ba; + vec2 center = fract(origin + bboxSize * vec2(0.5)); + + // Compute UV within bbox for this pixel (wrap-aware) + vec2 uvInBbox = mod(uv - origin + 1.0, 1.0) / bboxSize; + + // Per-island random values seeded from origin + vec2 randOffset = vec2(0.0); + { + float rx = randomFloatRange(origin, 4, -1.0, 1.0); + float ry = randomFloatRange(origin, 5, -1.0, 1.0); + randOffset = normalize(vec2(rx, ry)) * prop_posRand; + } + + float rot = randomFloatRange(origin, 3, -180.0, 180.0) * prop_rotRand + prop_rot; + + // Mask + if (mask_connected) { + float m = texture(mask, center).r; + if (m < 0.001) + return vec4(prop_bg.rgb, 1.0); + } + + // Scale + float s = prop_scale; + if (size_connected) + s *= texture(size, center).r; + float randScale = randomFloatRange(origin, 3, 0.0, 1.0); + s = mix(s, randScale, prop_scaleRand); + + // Intensity + float intens = 1.0; + if (intensity_connected) + intens *= texture(intensity, center).r; + float randIntensity = randomFloatRange(origin, 8, 0.0, 1.0); + intens = mix(intens, randIntensity, prop_intensityRand); + + vec2 finalUv = transformUV(uvInBbox, randOffset, rot, vec2(s)); + vec3 color = texture(image, finalUv).rgb; + + return vec4(color * intens, 1.0); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/floodfillv2tobbox.cpp b/src/texturelab/libraries/v3/floodfillv2tobbox.cpp new file mode 100644 index 00000000..728f231e --- /dev/null +++ b/src/texturelab/libraries/v3/floodfillv2tobbox.cpp @@ -0,0 +1,41 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +void FloodFillV2ToBBoxNode::init() +{ + this->title = "FF To BBox V2"; + + this->addInput("floodfill"); + + this->addEnumProp("function", "Function", + {"max(x,y)", "min(x,y)", "x", "y", "length(x,y)"}); + + auto source = R""""( + vec4 process(vec2 uv) + { + vec4 data = texture(floodfill, uv); + + if (data.ba == vec2(0.0, 0.0)) + return vec4(0.0, 0.0, 0.0, 1.0); + + // BA = bbox size (width, height) relative to texture + float intensity = 0.0; + + if (prop_function == 0) + intensity = max(data.b, data.a); + else if (prop_function == 1) + intensity = min(data.b, data.a); + else if (prop_function == 2) + intensity = data.b; + else if (prop_function == 3) + intensity = data.a; + else if (prop_function == 4) + intensity = sqrt(data.b * data.b + data.a * data.a); + + return vec4(vec3(intensity), 1.0); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/floodfillv2tocolor.cpp b/src/texturelab/libraries/v3/floodfillv2tocolor.cpp new file mode 100644 index 00000000..e1fa274c --- /dev/null +++ b/src/texturelab/libraries/v3/floodfillv2tocolor.cpp @@ -0,0 +1,29 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +void FloodFillV2ToColorNode::init() +{ + this->title = "FF To Color V2"; + + this->addInput("floodfill"); + this->addInput("color"); + + auto source = R""""( + vec4 process(vec2 uv) + { + vec4 data = texture(floodfill, uv); + + // Background check: bbox size is zero + if (data.ba == vec2(0.0, 0.0)) + return vec4(0.0, 0.0, 0.0, 1.0); + + // Origin is stored directly in RG — no reconstruction needed + vec2 origin = data.rg; + + return texture(color, origin); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/floodfillv2togradient.cpp b/src/texturelab/libraries/v3/floodfillv2togradient.cpp new file mode 100644 index 00000000..71072c4b --- /dev/null +++ b/src/texturelab/libraries/v3/floodfillv2togradient.cpp @@ -0,0 +1,56 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +void FloodFillV2ToGradientNode::init() +{ + this->title = "FF To Gradient V2"; + + this->addInput("floodfill"); + + this->addFloatProp("angle", "Angle", 0, 0, 360, 1); + this->addFloatProp("variation", "Angle Variation", 0, 0, 1.0, 0.05); + + auto source = R""""( + mat2 buildRot(float rot) + { + float r = radians(rot); + return mat2(cos(r), -sin(r), sin(r), cos(r)); + } + + float distAlongDir(vec2 x, vec2 dir) + { + return dot(x, dir) / dot(dir, dir); + } + + vec4 process(vec2 uv) + { + vec4 data = texture(floodfill, uv); + + if (data.ba == vec2(0.0, 0.0)) + return vec4(0.0, 0.0, 0.0, 1.0); + + vec2 origin = data.rg; + vec2 bboxSize = data.ba; + + float radius = length(bboxSize) * 0.5; + + // Per-island random rotation from origin seed + float rotRand = _rand(vec2(_seed) + origin * vec2(0.01)); + float addedRot = rotRand * 360.0 * prop_variation; + + vec2 dir = buildRot(prop_angle + addedRot) * vec2(-radius, 0); + + // Wrap-aware offset from center + vec2 offsetFromOrigin = mod(uv - origin + 1.0, 1.0); + vec2 centerToUv = offsetFromOrigin - bboxSize * vec2(0.5); + + float grad = distAlongDir(centerToUv, dir); + grad = grad * 0.5 + 0.5; + + return vec4(vec3(grad), 1.0); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/floodfillv2torandomcolor.cpp b/src/texturelab/libraries/v3/floodfillv2torandomcolor.cpp new file mode 100644 index 00000000..67667d71 --- /dev/null +++ b/src/texturelab/libraries/v3/floodfillv2torandomcolor.cpp @@ -0,0 +1,32 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +void FloodFillV2ToRandomColorNode::init() +{ + this->title = "FF To Random Color V2"; + + this->addInput("floodfill"); + + auto source = R""""( + vec4 process(vec2 uv) + { + vec4 data = texture(floodfill, uv); + + if (data.ba == vec2(0.0, 0.0)) + return vec4(0.0, 0.0, 0.0, 1.0); + + // Origin stored directly — use as hash seed + vec2 origin = data.rg; + + vec4 color = vec4(0.0, 0.0, 0.0, 1.0); + color.r = _rand(vec2(_seed) + origin + vec2(1) * vec2(0.01)); + color.g = _rand(vec2(_seed) + origin + vec2(2) * vec2(0.01)); + color.b = _rand(vec2(_seed) + origin + vec2(3) * vec2(0.01)); + + return color; + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/floodfillv2torandomintensity.cpp b/src/texturelab/libraries/v3/floodfillv2torandomintensity.cpp new file mode 100644 index 00000000..2b936071 --- /dev/null +++ b/src/texturelab/libraries/v3/floodfillv2torandomintensity.cpp @@ -0,0 +1,29 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +void FloodFillV2ToRandomIntensityNode::init() +{ + this->title = "FF To Random Intensity V2"; + + this->addInput("floodfill"); + + auto source = R""""( + vec4 process(vec2 uv) + { + vec4 data = texture(floodfill, uv); + + if (data.ba == vec2(0.0, 0.0)) + return vec4(0.0, 0.0, 0.0, 1.0); + + vec2 origin = data.rg; + + vec4 color = vec4(0.0, 0.0, 0.0, 1.0); + color.rgb = vec3(_rand(vec2(_seed) + origin + vec2(1) * vec2(0.01))); + + return color; + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/heightblend.cpp b/src/texturelab/libraries/v3/heightblend.cpp new file mode 100644 index 00000000..147cfff0 --- /dev/null +++ b/src/texturelab/libraries/v3/heightblend.cpp @@ -0,0 +1,65 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// https://blog.selfshadow.com/publications/blending-in-detail/ +// Barré-Brisebois & Bouchard, "Approximating Translucency for a Fast, Cheap +// and Convincing Subsurface-Scattering Look," GDC 2011 +// Height-aware material blend: the top layer wears away at high-height +// features of the bottom layer, revealing it underneath — physically +// plausible layering without the muddy result of linear alpha blending. +// Classic use: paint coat over scratched metal, where scratches cut through. +// Feed Curvature output as the mask for curvature-driven edge wear. +void HeightBlendNode::init() +{ + this->title = "Height Blend"; + + this->addInput("color_a"); // bottom material (e.g. bare metal) + this->addInput("color_b"); // top material (e.g. paint) + this->addInput("height_a"); // height map for bottom material + this->addInput("height_b"); // height map for top material + this->addInput("mask"); // wear mask: white=top present, black=bottom exposed + + this->addFloatProp("blend", "Blend Factor", 0.5, 0.0, 1.0, 0.01); + this->addFloatProp("edge_width", "Edge Width", 0.1, 0.0, 0.5, 0.01); + this->addFloatProp("contrast", "Contrast", 1.0, 0.1, 4.0, 0.1); + + auto source = R""""( + vec4 process(vec2 uv) + { + // Gather inputs; fall back gracefully if not connected + vec4 colA = color_a_connected ? texture(color_a, uv) : vec4(0.2, 0.2, 0.2, 1.0); + vec4 colB = color_b_connected ? texture(color_b, uv) : vec4(0.8, 0.8, 0.8, 1.0); + + float hA = height_a_connected ? texture(height_a, uv).r : 0.5; + float hB = height_b_connected ? texture(height_b, uv).r : 0.5; + float wearMask = mask_connected ? texture(mask, uv).r : prop_blend; + + // wearMask=1 → full top coat; wearMask=0 → fully worn to base + float coverage = clamp(wearMask * prop_blend * 2.0, 0.0, 1.0); + + // Height-based cutoff: find where the two height fields "meet" + float hAscaled = hA * (1.0 - coverage); + float hBscaled = hB * coverage; + float cutoff = max(hAscaled, hBscaled); + + float ew = max(prop_edge_width, 0.001); + + // Per-pixel blend factor from height intersection + float bFactor = smoothstep(cutoff - ew, cutoff + ew, hBscaled); + + // Apply contrast to the blend factor + if (prop_contrast != 1.0) { + bFactor = clamp( + (bFactor - 0.5) * prop_contrast + 0.5, + 0.0, 1.0 + ); + } + + vec4 result = mix(colA, colB, bFactor); + return result; + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/highpass.cpp b/src/texturelab/libraries/v3/highpass.cpp new file mode 100644 index 00000000..221a5349 --- /dev/null +++ b/src/texturelab/libraries/v3/highpass.cpp @@ -0,0 +1,50 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// https://en.wikipedia.org/wiki/Unsharp_masking +// Highpass = input - blur(input) + 0.5 +// Output is neutral-gray where there is no detail; overlay-blend it onto a +// base material to layer micro-variation without altering base tone. +void HighpassNode::init() +{ + this->title = "Highpass"; + + this->addInput("image"); + + this->addFloatProp("radius", "Radius", 8.0, 0.5, 64.0, 0.5); + this->addIntProp ("quality", "Quality", 4, 1, 8, 1); + this->addFloatProp("intensity", "Intensity", 1.0, 0.0, 4.0, 0.1); + + auto source = R""""( + vec4 process(vec2 uv) + { + if (!image_connected) + return vec4(0.5, 0.5, 0.5, 1.0); + + vec4 original = texture(image, uv); + + // Box blur approximation of Gaussian + vec4 blurred = vec4(0.0); + float total = 0.0; + int q = prop_quality; + + for (int x = -q; x <= q; x++) { + for (int y = -q; y <= q; y++) { + vec2 offset = vec2(float(x), float(y)) + * prop_radius / _textureSize.xy; + blurred += texture(image, uv + offset); + total += 1.0; + } + } + blurred /= total; + + // Highpass centered on 0.5 so it is neutral for Overlay blending + vec4 detail = (original - blurred) * prop_intensity + 0.5; + + return clamp(detail, 0.0, 1.0); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/linecellv3.cpp b/src/texturelab/libraries/v3/linecellv3.cpp new file mode 100644 index 00000000..236bfe03 --- /dev/null +++ b/src/texturelab/libraries/v3/linecellv3.cpp @@ -0,0 +1,86 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// Voronoi edge (line-cell) node with independent X/Y scale. +// Renders the borders between Voronoi cells as bright lines. +// Seamless: integer-rounded grid sizes with mod()-wrapped cell hashes. +// Reference: https://iquilezles.org/articles/voronoilines/ +void LineCellV3Node::init() +{ + this->title = "Line Cell"; + + this->addFloatProp("scale", "Scale", 30.0, 1.0, 256.0, 1.0); + this->addFloatProp("scaleX", "Scale X", 1.0, 0.1, 10.0, 0.1); + this->addFloatProp("scaleY", "Scale Y", 1.0, 0.1, 10.0, 0.1); + this->addBoolProp ("invert", "Invert", false); + this->addFloatProp("entropy", "Order", 0.0, 0.0, 1.0, 0.01); + this->addFloatProp("intensity", "Intensity", 1.0, 0.0, 2.0, 0.01); + this->addFloatProp("thickness", "Line Thickness", 0.05, 0.0, 0.3, 0.005); + + this->setShaderSource(R""""( + // Returns (edgeDist, nearestDiff.xy). + // edgeDist = perpendicular distance to the nearest Voronoi edge. + vec3 voronoiEdge(vec2 scaledUV, float gsX, float gsY) + { + vec2 i_st = floor(scaledUV); + vec2 f_st = fract(scaledUV); + vec2 seedOff = vec2(_seed * 0.173, _seed * 0.251); + + // Pass 1: find nearest cell. + float md = 1e9; + vec2 mg = vec2(0.0); + vec2 mr = vec2(0.0); + + for (int y = -1; y <= 1; y++) { + for (int x = -1; x <= 1; x++) { + vec2 neighbor = vec2(float(x), float(y)); + vec2 wrapped = mod(i_st + neighbor, vec2(gsX, gsY)); + vec2 point = hash22(wrapped + seedOff); + point = mix(point, vec2(0.5), prop_entropy); + vec2 diff = neighbor + point - f_st; + float dist = length(diff); + if (dist < md) { + md = dist; + mr = diff; + mg = neighbor; + } + } + } + + // Pass 2: distance to bisector of nearest and second-nearest cells. + float edgeDist = 1e9; + for (int j = -2; j <= 2; j++) { + for (int i = -2; i <= 2; i++) { + vec2 neighbor = mg + vec2(float(i), float(j)); + vec2 wrapped = mod(i_st + neighbor, vec2(gsX, gsY)); + vec2 point = hash22(wrapped + seedOff); + point = mix(point, vec2(0.5), prop_entropy); + vec2 diff = neighbor + point - f_st; + if (dot(mr - diff, mr - diff) > 0.00001) + edgeDist = min(edgeDist, + dot(0.5 * (mr + diff), normalize(diff - mr))); + } + } + + return vec3(edgeDist, mr); + } + + vec4 process(vec2 uv) + { + float gsX = max(1.0, floor(prop_scale * prop_scaleX + 0.5)); + float gsY = max(1.0, floor(prop_scale * prop_scaleY + 0.5)); + + vec2 scaledUV = vec2(uv.x * gsX, uv.y * gsY); + vec3 c = voronoiEdge(scaledUV, gsX, gsY); + + // c.x = 0 at edge centre, grows away from it. + // Smooth bright line that fades to black past `thickness`. + float edge = 1.0 - smoothstep(0.0, prop_thickness, c.x); + vec3 color = vec3(edge); + + if (prop_invert) color = 1.0 - color; + return vec4(color * prop_intensity, 1.0); + } + )""""); +} diff --git a/src/texturelab/libraries/v3/makeittile.cpp b/src/texturelab/libraries/v3/makeittile.cpp new file mode 100644 index 00000000..5f397c83 --- /dev/null +++ b/src/texturelab/libraries/v3/makeittile.cpp @@ -0,0 +1,64 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// https://iquilezles.org/articles/texturerepetition/ (complementary approach) +// Adobe Substance Designer "Make It Tile" reference behaviour. +// +// Offset-cross-blend tiling: +// 1. Sample the input at uv +// 2. Sample the input again at fract(uv + 0.5) — a half-period shift +// 3. Blend toward the shifted copy near the tile edges; keep the original +// near the centre. +// +// At any tile boundary (uv.x = 0 or 1, uv.y = 0 or 1) the weight is 0 so the +// output reduces to the shifted sample, which evaluates to texture(0.5, *) on +// both sides of the seam — exactly matching across the boundary. +// Tileability is therefore mathematically guaranteed, not approximate. +// +// `Horizontal` and `Vertical` independently toggle which axis is fixed; +// disable either if the input is already tileable in that direction so the +// node leaves it untouched. +void MakeItTileNode::init() +{ + this->title = "Make It Tile"; + + this->addInput("image"); + + this->addFloatProp("blend_width", "Blend Width", 0.2, 0.02, 0.5, 0.01); + this->addBoolProp ("horizontal", "Horizontal", true); + this->addBoolProp ("vertical", "Vertical", true); + + auto source = R""""( + vec4 process(vec2 uv) + { + if (!image_connected) + return vec4(0.0, 0.0, 0.0, 1.0); + + // Sample the original + vec4 orig = texture(image, uv); + + // Build the half-period shift on enabled axes only. + // Disabled axes use 0 shift, which means orig == shifted along + // that axis — the blend collapses to a no-op for that direction. + vec2 shift = vec2(prop_horizontal ? 0.5 : 0.0, + prop_vertical ? 0.5 : 0.0); + vec2 sUV = fract(uv + shift); + vec4 shifted = texture(image, sUV); + + // Distance to the nearest edge on each enabled axis. + // For disabled axes use 1.0 so the axis never reduces minD. + float dx = prop_horizontal ? min(uv.x, 1.0 - uv.x) : 1.0; + float dy = prop_vertical ? min(uv.y, 1.0 - uv.y) : 1.0; + float minD = min(dx, dy); + + // Blend weight: 0 at the seam → use shifted; 1 in centre → use original + float bw = max(prop_blend_width, 0.001); + float w = smoothstep(0.0, bw, minD); + + return mix(shifted, orig, w); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/maskedblur.cpp b/src/texturelab/libraries/v3/maskedblur.cpp new file mode 100644 index 00000000..d1f20c7d --- /dev/null +++ b/src/texturelab/libraries/v3/maskedblur.cpp @@ -0,0 +1,62 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +void MaskedBlurNode::init() +{ + this->title = "Masked Blur"; + this->addInput("image"); + this->addInput("mask"); + + this->addFloatProp("intensity", "Intensity", 1.0, 0.0, 10.0, 0.1); + this->addIntProp("samples", "Samples", 50, 1, 100, 1); + this->addFloatProp("opacity", "Opacity", 0.5, 0.0, 1.0, 0.01); + + auto source = R""""( + #define PI 3.14159265359 + #define POW2(x) ((x) * (x)) + + float gaussian(vec2 i, float sigma) + { + return 1.0 / (2.0 * PI * POW2(sigma)) + * exp(-(POW2(i.x) + POW2(i.y)) / (2.0 * POW2(sigma))); + } + + vec3 blur(sampler2D sp, vec2 uv, vec2 scale) + { + vec3 col = vec3(0.0); + float accum = 0.0; + float sigma = float(prop_samples) * 0.25; + int half_samples = prop_samples / 2; + + for (int x = -half_samples; x < half_samples; x++) + for (int y = -half_samples; y < half_samples; y++) + { + vec2 offset = vec2(float(x), float(y)); + float weight = gaussian(offset, sigma); + col += texture(sp, uv + scale * offset).rgb * weight; + accum += weight; + } + + return col / accum; + } + + vec4 process(vec2 uv) + { + if (!image_connected) + return vec4(0.0, 0.0, 0.0, 1.0); + + float maskVal = mask_connected ? 1.0 - texture(mask, uv).r : 1.0; + float blurAmount = 1.0 - maskVal * prop_opacity; + + vec2 ps = vec2(1.0) / _textureSize; + vec4 color; + color.rgb = blur(image, uv, ps * prop_intensity * blurAmount); + color.a = 1.0; + + return color; + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/normalmapv3.cpp b/src/texturelab/libraries/v3/normalmapv3.cpp new file mode 100644 index 00000000..898c0fe5 --- /dev/null +++ b/src/texturelab/libraries/v3/normalmapv3.cpp @@ -0,0 +1,87 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +void NormalMapV3Node::init() +{ + this->title = "Normal Map"; + this->addInput("height"); + + this->addEnumProp("filter", "Filter", {"Simple", "Sobel", "Scharr"}); + this->addEnumProp("channel", "Channel", {"Red", "Green", "Blue", "Alpha", "Luminance"}); + this->addFloatProp("strength", "Strength", 1.0, -4.0, 4.0, 0.05); + this->addIntProp("range", "Range", 1, 1, 20, 1); + this->addBoolProp("res_ind", "Resolution Independent", false); + this->addIntProp("ref_res", "Reference Resolution", 1024, 64, 8192, 64); + this->addEnumProp("convention", "Convention", {"OpenGL (Y+)", "DirectX (Y-)"}); + + auto source = R""""( + // Extract a single channel from the height input. + // Channel: 0=R 1=G 2=B 3=A 4=Luminance(BT.709) + float sampleChannel(vec2 uv) + { + vec4 s = texture(height, uv); + if (prop_channel == 1) return s.g; + if (prop_channel == 2) return s.b; + if (prop_channel == 3) return s.a; + if (prop_channel == 4) return dot(s.rgb, vec3(0.2126, 0.7152, 0.0722)); + return s.r; + } + + vec4 process(vec2 uv) + { + vec2 step = (vec2(1.0) / _textureSize) * float(prop_range); + if (prop_res_ind) + step = (vec2(1.0) / float(prop_ref_res)) * float(prop_range); + + // Scale matches V2's effective strength (prop_strength * 0.1 / 2.0). + // The cross-product construction below naturally incorporates step.x + // into the z-component, keeping XY and Z in the same magnitude range. + float scale = prop_strength * 0.05; + float gx, gy; + + if (prop_filter == 0) { + // Simple: forward-difference (3-tap), max response = 1.0 + float c = sampleChannel(uv); + float r = sampleChannel(uv + vec2( step.x, 0.0)); + float u = sampleChannel(uv + vec2( 0.0, step.y)); + gx = r - c; + gy = u - c; + } else { + // 3x3 neighbourhood for Sobel / Scharr + float tl = sampleChannel(uv + vec2(-step.x, step.y)); + float tc = sampleChannel(uv + vec2( 0.0, step.y)); + float tr = sampleChannel(uv + vec2( step.x, step.y)); + float ml = sampleChannel(uv + vec2(-step.x, 0.0)); + float mr = sampleChannel(uv + vec2( step.x, 0.0)); + float bl = sampleChannel(uv + vec2(-step.x, -step.y)); + float bc = sampleChannel(uv + vec2( 0.0, -step.y)); + float br = sampleChannel(uv + vec2( step.x, -step.y)); + + if (prop_filter == 1) { + // Sobel — max response = 4.0, normalise so strength is + // perceptually equivalent to Simple at the same value + gx = (-tl + tr - 2.0*ml + 2.0*mr - bl + br) / 4.0; + gy = ( tl + 2.0*tc + tr - bl - 2.0*bc - br) / 4.0; + } else { + // Scharr — max response = 16.0 + gx = (-3.0*tl + 3.0*tr - 10.0*ml + 10.0*mr - 3.0*bl + 3.0*br) / 16.0; + gy = ( 3.0*tl + 10.0*tc + 3.0*tr - 3.0*bl - 10.0*bc - 3.0*br) / 16.0; + } + } + + // Cross product of surface tangent vectors — step.x in the z slot keeps + // the XY/Z ratio consistent regardless of texture resolution or range. + vec3 dvx = vec3(step.x, 0.0, gx * scale); + vec3 dvy = vec3(0.0, step.y, gy * scale); + vec3 normal = normalize(cross(dvx, dvy)); + + // Convention: OpenGL = Y+ (green-up), DirectX = Y- (green-down) + if (prop_convention == 1) normal.y = -normal.y; + + return vec4(normal * 0.5 + 0.5, 1.0); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/perlinnoise.cpp b/src/texturelab/libraries/v3/perlinnoise.cpp new file mode 100644 index 00000000..d66f2312 --- /dev/null +++ b/src/texturelab/libraries/v3/perlinnoise.cpp @@ -0,0 +1,99 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// Classic 2D gradient (Perlin) noise, upgraded for v3: +// - Quintic smooth step: C2-continuous, no gradient discontinuities at cell +// edges +// - Normalized pseudo-random gradients via hash22 → unit circle +// - Seamless tiling: integer grid + mod()-wrapped cell indices +// - fBm with per-octave lacunarity / gain controls +// - Three output modes: Standard, Ridged, Billowy +// +// References: +// Perlin, "An Image Synthesizer," SIGGRAPH 1985 +// https://iquilezles.org/articles/gradientnoise/ — gradient noise primer +void PerlinNoiseNode::init() +{ + this->title = "Perlin Noise"; + + auto modeProp = this->addEnumProp("output_mode", "Output", + {"Standard", "Ridged", "Billowy"}); + modeProp->index = 0; + + this->addIntProp("scale", "Scale", 4, 1, 12, 1); + this->addIntProp("octaves", "Octaves", 6, 1, 8, 1); + this->addFloatProp("lacunarity", "Lacunarity", 2.0, 1.5, 4.0, 0.1); + this->addFloatProp("gain", "Gain", 0.5, 0.2, 0.8, 0.05); + + auto source = R""""( + #define OUT_STANDARD 0 + #define OUT_RIDGED 1 + #define OUT_BILLOWY 2 + + // 2D gradient noise. + // gridSize must be a positive integer so that mod() wrapping produces + // seamless tiling: cell (gridSize) hashes identically to cell 0. + float gnoise(vec2 uv, float gridSize) { + vec2 p = uv * gridSize; + vec2 i = floor(p); + vec2 f = fract(p); + + // Quintic interpolant: 6t^5 - 15t^4 + 10t^3 (zero first & second derivative at 0 and 1) + vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); + + vec2 sOff = vec2(_seed * 0.173, _seed * 0.251); + vec2 gs = vec2(gridSize); + + // Unit-circle gradients from hash22 (maps [0,1]^2 → [-1,1]^2, then normalize) + vec2 g00 = normalize(-1.0 + 2.0 * hash22(mod(i, gs) + sOff)); + vec2 g10 = normalize(-1.0 + 2.0 * hash22(mod(i + vec2(1,0), gs) + sOff)); + vec2 g01 = normalize(-1.0 + 2.0 * hash22(mod(i + vec2(0,1), gs) + sOff)); + vec2 g11 = normalize(-1.0 + 2.0 * hash22(mod(i + vec2(1,1), gs) + sOff)); + + // Dot gradient with distance-to-corner vector + float n00 = dot(g00, f); + float n10 = dot(g10, f - vec2(1,0)); + float n01 = dot(g01, f - vec2(0,1)); + float n11 = dot(g11, f - vec2(1,1)); + + // Scale ~1/sqrt(0.5) ≈ 1.41 to stretch typical ±0.7 output to ±1 + return 1.41 * mix(mix(n00, n10, u.x), mix(n01, n11, u.x), u.y); + } + + vec4 process(vec2 uv) { + float value = 0.0; + float amp = 0.5; + float freq = float(prop_scale); + float maxAmp = 0.0; + + for (int i = 0; i < prop_octaves; i++) { + // Round to integer so every octave grid tiles exactly + float gs = max(1.0, floor(freq + 0.5)); + float n = gnoise(uv, gs); // in approximately [-1, 1] + + if (prop_output_mode == OUT_RIDGED) + n = 1.0 - abs(n); // sharp ridges at zero-crossings + else if (prop_output_mode == OUT_BILLOWY) + n = abs(n); // rounded bumps everywhere + + value += amp * n; + maxAmp += amp; + freq *= prop_lacunarity; + amp *= prop_gain; + } + + float f = value / maxAmp; + + float result; + if (prop_output_mode == OUT_STANDARD) + result = clamp(0.5 + 0.5 * f, 0.0, 1.0); + else + result = clamp(f, 0.0, 1.0); + + return vec4(vec3(result), 1.0); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/perlinnoise3d.cpp b/src/texturelab/libraries/v3/perlinnoise3d.cpp new file mode 100644 index 00000000..ef674cce --- /dev/null +++ b/src/texturelab/libraries/v3/perlinnoise3d.cpp @@ -0,0 +1,151 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// 3D gradient (Perlin) noise for texture work. +// +// The core insight: sampling a 2D cross-section through a 3D noise volume +// gives patterns that a purely 2D noise cannot — specifically wood grain and +// marble veins, the two applications Perlin described in his original 1985 +// paper. The Z Slice prop lets you navigate through the volume; rotating +// the cut angle gives completely different grain/vein patterns from the same +// seed, just as cutting a log at different angles produces different grain. +// +// Tiling: XY cell indices are mod()-wrapped at the integer grid boundary so +// the noise repeats seamlessly. Z is intentionally left free — tiling in Z +// would create visible repetition along the depth axis. +// +// Modes: +// Standard — raw fBm, remapped to [0,1] +// Ridged — 1 - |n| per octave → sharp mountain-ridge crests +// Billowy — |n| per octave → rounded pillow-cloud bumps +// Wood — noise-distorted concentric rings (log cross-section) +// Marble — noise-distorted sine stripes (classic Perlin marble veining) +// +// References: +// Perlin, "An Image Synthesizer," SIGGRAPH 1985 +// https://iquilezles.org/articles/gradientnoise/ +void PerlinNoise3DNode::init() +{ + this->title = "Perlin Noise 3D"; + + auto modeProp = this->addEnumProp("output_mode", "Output", + {"Standard", "Ridged", "Billowy", + "Wood", "Marble"}); + modeProp->index = 0; + + this->addIntProp ("scale", "Scale", 4, 1, 12, 1); + this->addFloatProp("z_offset", "Z Slice", 0.0, 0.0, 10.0, 0.1); + this->addIntProp ("octaves", "Octaves", 6, 1, 8, 1); + this->addFloatProp("lacunarity", "Lacunarity", 2.0, 1.5, 4.0, 0.1); + this->addFloatProp("gain", "Gain", 0.5, 0.2, 0.8, 0.05); + this->addFloatProp("distortion", "Distortion", 2.0, 0.0, 8.0, 0.1); + + auto source = R""""( + #define OUT_STANDARD 0 + #define OUT_RIDGED 1 + #define OUT_BILLOWY 2 + #define OUT_WOOD 3 + #define OUT_MARBLE 4 + + // Unit-sphere gradient for a 3D cell. + // XY indices are mod()-wrapped for seamless XY tiling; Z is free. + vec3 grad3(vec3 cell, float gridSize) { + vec2 wrapped = mod(cell.xy, vec2(gridSize)); + vec3 p = vec3(wrapped, cell.z) + + vec3(_seed * 0.173, _seed * 0.251, _seed * 0.317); + p = fract(p * vec3(0.1031, 0.1030, 0.0973)); + p += dot(p, p.yzx + 33.33); + return normalize(fract((p.xxy + p.yxx) * p.zyx) * 2.0 - 1.0); + } + + // 3D gradient noise with quintic interpolation. + // Scale factor 1.155 ≈ 1/sqrt(0.75): normalises the theoretical + // max of sqrt(3/4) for unit gradients in 3D to approximately ±1. + float gnoise3(vec2 uv, float z, float gridSize) { + vec3 p = vec3(uv * gridSize, z); + vec3 i = floor(p); + vec3 f = fract(p); + vec3 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); + + float n000 = dot(grad3(i + vec3(0,0,0), gridSize), f - vec3(0,0,0)); + float n100 = dot(grad3(i + vec3(1,0,0), gridSize), f - vec3(1,0,0)); + float n010 = dot(grad3(i + vec3(0,1,0), gridSize), f - vec3(0,1,0)); + float n110 = dot(grad3(i + vec3(1,1,0), gridSize), f - vec3(1,1,0)); + float n001 = dot(grad3(i + vec3(0,0,1), gridSize), f - vec3(0,0,1)); + float n101 = dot(grad3(i + vec3(1,0,1), gridSize), f - vec3(1,0,1)); + float n011 = dot(grad3(i + vec3(0,1,1), gridSize), f - vec3(0,1,1)); + float n111 = dot(grad3(i + vec3(1,1,1), gridSize), f - vec3(1,1,1)); + + return 1.155 * mix( + mix(mix(n000, n100, u.x), mix(n010, n110, u.x), u.y), + mix(mix(n001, n101, u.x), mix(n011, n111, u.x), u.y), + u.z); + } + + // fBm over gnoise3. Ridged/Billowy transformations are applied + // per-octave so the feedback shapes fine detail correctly. + // Wood/Marble use the raw accumulated noise as turbulence. + float fbm3(vec2 uv, float z) { + float value = 0.0; + float amp = 0.5; + float freq = float(prop_scale); + float zScale = 1.0; + float maxAmp = 0.0; + + for (int i = 0; i < prop_octaves; i++) { + float gs = max(1.0, floor(freq + 0.5)); + float n = gnoise3(uv, z * zScale, gs); + + if (prop_output_mode == OUT_RIDGED) + n = 1.0 - abs(n); + else if (prop_output_mode == OUT_BILLOWY) + n = abs(n); + + value += amp * n; + maxAmp += amp; + freq *= prop_lacunarity; + zScale *= prop_lacunarity; + amp *= prop_gain; + } + + return value / maxAmp; + } + + vec4 process(vec2 uv) { + float f = fbm3(uv, prop_z_offset); + + float result; + + if (prop_output_mode == OUT_STANDARD) { + result = clamp(0.5 + 0.5 * f, 0.0, 1.0); + + } else if (prop_output_mode == OUT_RIDGED || + prop_output_mode == OUT_BILLOWY) { + result = clamp(f, 0.0, 1.0); + + } else if (prop_output_mode == OUT_WOOD) { + // Concentric rings centered on UV (0.5, 0.5), distorted by + // the noise turbulence. Scale drives ring frequency; + // Distortion controls how wavy the rings become. + float rings = length(uv - 0.5) * float(prop_scale) * 2.0 + + f * prop_distortion; + result = 0.5 + 0.5 * cos(rings * 6.28318530); + + } else { // MARBLE + // Horizontal bands distorted by noise turbulence — the + // canonical Perlin marble. Scale controls vein frequency; + // Distortion controls how much the noise bends the veins. + float vein = uv.x * float(prop_scale) + + f * prop_distortion; + float m = sin(vein * 3.14159265); + // Gamma curve sharpens vein edges for a more realistic look + result = clamp(pow(abs(m), 0.6) * sign(m) * 0.5 + 0.5, 0.0, 1.0); + } + + return vec4(vec3(result), 1.0); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/rays.cpp b/src/texturelab/libraries/v3/rays.cpp new file mode 100644 index 00000000..11a90370 --- /dev/null +++ b/src/texturelab/libraries/v3/rays.cpp @@ -0,0 +1,29 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +void RaysNode::init() +{ + this->title = "Rays"; + + this->addIntProp("sides", "Sides", 3, 1, 32, 1); + this->addFloatProp("angle", "Angle", 0.0, 0.0, 360.0, 1.0); + this->addFloatProp("translateX", "Translate X", 0.5, 0.0, 1.0, 0.01); + this->addFloatProp("translateY", "Translate Y", 0.5, 0.0, 1.0, 0.01); + + auto source = R""""( + #define PI 3.14159265359 + + vec4 process(vec2 uv) + { + uv -= vec2(prop_translateX, prop_translateY); + + float a = atan(uv.x, uv.y) + radians(prop_angle); + float shape = sin(a * float(prop_sides)); + + return vec4(vec3(shape), 1.0); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/roughgrain.cpp b/src/texturelab/libraries/v3/roughgrain.cpp new file mode 100644 index 00000000..64f7a0a6 --- /dev/null +++ b/src/texturelab/libraries/v3/roughgrain.cpp @@ -0,0 +1,92 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// Rough Grain — dense field of fine granular dots/specks. +// Produced by layered anisotropic noise with random per-layer orientation and +// per-column phase shifts; the high-frequency phase aliasing breaks the +// would-be streaks into isotropic grains. +// Uses the engine-wide _seed uniform so a single Random Seed change in the +// project regenerates this grain consistently with other procedural nodes. +// Use as: micro-roughness variation on painted surfaces, sandblasted metal +// base, fine textile noise, or the granular substrate of any material. +void RoughGrainNode::init() +{ + this->title = "Rough Grain"; + + this->addInput("mask"); + + this->addIntProp ("density", "Density", 400, 10, 2000, 10); + this->addFloatProp("grain_size", "Grain Size", 0.04, 0.001, 0.3, 0.001); + this->addFloatProp("variation", "Variation", 0.5, 0.0, 1.0, 0.01); + this->addFloatProp("intensity", "Intensity", 1.0, 0.0, 2.0, 0.05); + this->addIntProp ("layers", "Layers", 2, 1, 4, 1); + + auto source = R""""( + #define PI 3.14159265358979 + + // 2-D hash with engine-wide seed offset + float grainHash(vec2 p) { + p += vec2(_seed * 0.127, _seed * 0.311); + return hash12(p); + } + + // One layer of anisotropic noise at a given orientation and frequency. + // When the perpendicular axis is stretched far more than the primary + // axis, the per-column random phase aliases the would-be streaks into + // tightly packed dots — that grain effect is the whole point. + float grainLayer(vec2 uv, float freq, float angleRad) { + float cosA = cos(angleRad); + float sinA = sin(angleRad); + + vec2 rot = vec2(uv.x * cosA + uv.y * sinA, + -uv.x * sinA + uv.y * cosA); + + // Heavy anisotropic scaling (drives the dot aliasing) + rot.x *= freq * 0.05; + rot.y *= freq * 10.0; + + // Per-column phase perturbation + float perturb = (grainHash(vec2(rot.x, 0.0)) * 2.0 - 1.0) + * prop_variation * 0.5; + rot.y += perturb * freq; + + float phase = grainHash(vec2(floor(rot.y * float(prop_density)), 0.731)); + + float pos = fract(rot.y * float(prop_density) + phase); + float halfW = prop_grain_size * 0.5; + float grain = smoothstep(0.5 - halfW, 0.5, pos) + * (1.0 - smoothstep(0.5, 0.5 + halfW, pos)); + + return grain; + } + + vec4 process(vec2 uv) + { + float result = 0.0; + float weight = 1.0; + float totalW = 0.0; + + for (int i = 0; i < prop_layers; i++) { + // Random orientation per layer — multi-directional dot field + float layerAngle = grainHash(vec2(float(i) * 3.7, 1.1)) * PI * 2.0; + float freq = 1.0 + float(i) * 0.5; + + result += grainLayer(uv, freq, layerAngle) * weight; + totalW += weight; + weight *= 0.55; + } + + result = (totalW > 0.0) ? result / totalW : 0.0; + result = clamp(result * prop_intensity, 0.0, 1.0); + + if (mask_connected) { + result *= texture(mask, uv).r; + } + + return vec4(vec3(result), 1.0); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/setalpha.cpp b/src/texturelab/libraries/v3/setalpha.cpp new file mode 100644 index 00000000..7c6a3d4c --- /dev/null +++ b/src/texturelab/libraries/v3/setalpha.cpp @@ -0,0 +1,51 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// Quick utility to attach an alpha channel to a texture: RGB comes from +// `rgba`, alpha comes from a chosen channel of `alpha`. Saves building a +// 4-input RGBA Merge graph just to swap one channel. +void SetAlphaNode::init() +{ + this->title = "Set Alpha"; + + this->addInput("rgba"); + this->addInput("alpha"); + + auto prop = this->addEnumProp( + "alphaChannel", "Alpha Channel", + {"Red", "Green", "Blue", "Alpha", "Average (RGB)"}); + prop->setValue(0); + + this->addBoolProp("invert", "Invert Alpha", false); + + auto source = R""""( + float getChannel(vec4 inputData, int mode) + { + if (mode == 0) return inputData.r; + if (mode == 1) return inputData.g; + if (mode == 2) return inputData.b; + if (mode == 3) return inputData.a; + if (mode == 4) { + return (inputData.r + inputData.g + inputData.b) * 0.3333333; + } + + return 0.0; + } + + vec4 process(vec2 uv) + { + vec3 rgb = rgba_connected ? texture(rgba, uv).rgb : vec3(0.0); + + float a = 1.0; + if (alpha_connected) { + a = getChannel(texture(alpha, uv), prop_alphaChannel); + if (prop_invert) a = 1.0 - a; + } + + return vec4(rgb, a); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/solidcellv3.cpp b/src/texturelab/libraries/v3/solidcellv3.cpp new file mode 100644 index 00000000..6fdf811b --- /dev/null +++ b/src/texturelab/libraries/v3/solidcellv3.cpp @@ -0,0 +1,56 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// Voronoi solid-fill cell node with independent X/Y scale. +// Each cell gets a unique random grayscale value derived from its wrapped index, +// so opposite UV boundaries share identical cell hashes — seamless tiling. +void SolidCellV3Node::init() +{ + this->title = "Solid Cell"; + + this->addFloatProp("scale", "Scale", 30.0, 1.0, 256.0, 1.0); + this->addFloatProp("scaleX", "Scale X", 1.0, 0.1, 10.0, 0.1); + this->addFloatProp("scaleY", "Scale Y", 1.0, 0.1, 10.0, 0.1); + this->addBoolProp ("invert", "Invert", false); + this->addFloatProp("entropy", "Order", 0.0, 0.0, 1.0, 0.01); + this->addFloatProp("intensity", "Intensity", 1.0, 0.0, 2.0, 0.01); + + this->setShaderSource(R""""( + vec4 process(vec2 uv) + { + float gsX = max(1.0, floor(prop_scale * prop_scaleX + 0.5)); + float gsY = max(1.0, floor(prop_scale * prop_scaleY + 0.5)); + + vec2 scaledUV = vec2(uv.x * gsX, uv.y * gsY); + vec2 i_st = floor(scaledUV); + vec2 f_st = fract(scaledUV); + + vec2 seedOff = vec2(_seed * 0.173, _seed * 0.251); + float m_dist = 1e9; + vec2 closestWrapped = vec2(0.0); + + for (int y = -1; y <= 1; y++) { + for (int x = -1; x <= 1; x++) { + vec2 neighbor = vec2(float(x), float(y)); + vec2 wrapped = mod(i_st + neighbor, vec2(gsX, gsY)); + vec2 point = hash22(wrapped + seedOff); + point = mix(point, vec2(0.5), prop_entropy); + vec2 diff = neighbor + point - f_st; + float dist = length(diff); + if (dist < m_dist) { + m_dist = dist; + closestWrapped = wrapped; + } + } + } + + // Use a second hash to generate per-cell color so it is independent + // of the cell-point position hash used above. + float cellColor = hash22(closestWrapped + vec2(_seed * 0.391, _seed * 0.617)).x; + vec3 color = vec3(cellColor); + if (prop_invert) color = 1.0 - color; + return vec4(color * prop_intensity, 1.0); + } + )""""); +} diff --git a/src/texturelab/libraries/v3/spread.cpp b/src/texturelab/libraries/v3/spread.cpp new file mode 100644 index 00000000..f947d1cc --- /dev/null +++ b/src/texturelab/libraries/v3/spread.cpp @@ -0,0 +1,220 @@ +#include "../../graphics/noderenderer.h" +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +#include +#include + +// Color Spread — JFA nearest-seed lookup that samples a color map instead of +// computing a distance value. Each masked-out (black) pixel receives the exact +// color of the closest foreground pixel in the mask, producing solid filled +// regions with no gradient blending. +// +// Use case: flood-fill a pattern with random intensities, then spread those +// colors outward to cover the lines/gaps in the pattern. + +// ============================================================================ +// ColorSpreadRenderData +// ============================================================================ +struct ColorSpreadRenderData : public NodeRenderData { + float threshold = 0.5f; + float distance = 1.0f; +}; + +// ============================================================================ +// ColorSpreadRenderer +// ============================================================================ +class ColorSpreadRenderer : public NodeTextureRenderer { +public: + void render(NodeRenderContext& ctx, const NodeRenderData& baseData) override + { + auto& data = static_cast(baseData); + auto gl = ctx.gl; + auto cache = ctx.cache; + int w = ctx.textureWidth; + int h = ctx.textureHeight; + + // Require at least the mask input; color map is optional (falls back to + // mask) + if (ctx.inputs.isEmpty() || ctx.inputs[0].textureId == 0) { + cache->bindFboToTexture(ctx.outputTextureId); + gl->glViewport(0, 0, w, h); + gl->glClearColor(0, 0, 0, 1); + gl->glClear(GL_COLOR_BUFFER_BIT); + return; + } + + GLuint maskTex = ctx.inputs[0].textureId; + GLuint colorTex = + (ctx.inputs.size() > 1 && ctx.inputs[1].textureId != 0) + ? ctx.inputs[1].textureId + : maskTex; + + GLuint seedShader = cache->getOrCompileShader( + "cs_seed", RenderResourceCache::standardVertexSource(), seedFrag()); + GLuint jfaShader = cache->getOrCompileShader( + "cs_jfa", RenderResourceCache::standardVertexSource(), jfaFrag()); + GLuint sampleShader = cache->getOrCompileShader( + "cs_sample", RenderResourceCache::standardVertexSource(), + sampleFrag()); + + GLuint texA = cache->acquireTexture(w, h); + GLuint texB = cache->acquireTexture(w, h); + + // Seed pass: foreground pixels (mask >= threshold) write their UV; + // background pixels write the sentinel (-1, -1). + cache->bindFboToTexture(texA); + ctx.useShader(seedShader); + ctx.bindTexture(seedShader, "u_mask", maskTex, 0); + gl->glUniform1f(gl->glGetUniformLocation(seedShader, "u_threshold"), + data.threshold); + ctx.drawQuad(); + + // JFA passes: propagate nearest-seed UV across the texture + int maxDim = std::max(w, h); + int stepSize = maxDim / 2; + while (stepSize >= 1) { + cache->bindFboToTexture(texB); + ctx.useShader(jfaShader); + ctx.bindTexture(jfaShader, "u_input", texA, 0); + gl->glUniform1i(gl->glGetUniformLocation(jfaShader, "u_step"), + stepSize); + ctx.drawQuad(); + std::swap(texA, texB); + stepSize /= 2; + } + + // Sample pass: look up the color map at the nearest-seed UV + cache->bindFboToTexture(ctx.outputTextureId); + ctx.useShader(sampleShader); + ctx.bindTexture(sampleShader, "u_jfa", texA, 0); + ctx.bindTexture(sampleShader, "u_color", colorTex, 1); + gl->glUniform1f(gl->glGetUniformLocation(sampleShader, "u_distance"), + data.distance); + ctx.drawQuad(); + + cache->releaseTexture(texA); + cache->releaseTexture(texB); + } + +private: + static QString seedFrag() + { + return R""""( + #version 150 + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_mask; + uniform float u_threshold; + + void main() + { + float v = texture(u_mask, v_texCoord).r; + if (v >= u_threshold) + fragColor = vec4(v_texCoord, 0.0, 1.0); + else + fragColor = vec4(-1.0, -1.0, 0.0, 1.0); + } + )""""; + } + + static QString jfaFrag() + { + return R""""( + #version 150 + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_input; + uniform int u_step; + + void main() + { + vec2 texSize = vec2(textureSize(u_input, 0)); + vec2 step = vec2(float(u_step)) / texSize; + + vec2 bestUV = vec2(-1.0); + float bestDist = 1e9; + + for (int x = -1; x <= 1; x++) { + for (int y = -1; y <= 1; y++) { + vec2 sampleUV = v_texCoord + vec2(float(x), float(y)) * step; + vec4 s = texture(u_input, sampleUV); + vec2 seedUV = s.rg; + if (seedUV.x < 0.0) continue; + float d = length(v_texCoord - seedUV); + if (d < bestDist) { + bestDist = d; + bestUV = seedUV; + } + } + } + + fragColor = vec4(bestUV, 0.0, 1.0); + } + )""""; + } + + // Sample the color map at the nearest-seed UV for a solid, gradient-free + // fill + static QString sampleFrag() + { + return R""""( + #version 150 + in vec2 v_texCoord; + out vec4 fragColor; + + uniform sampler2D u_jfa; + uniform sampler2D u_color; + uniform float u_distance; + + void main() + { + vec2 seedUV = texture(u_jfa, v_texCoord).rg; + + if (seedUV.x < 0.0) { + fragColor = vec4(0.0, 0.0, 0.0, 1.0); + } else { + float d = length(v_texCoord - seedUV); + if (d > u_distance * 0.5) + fragColor = vec4(0.0, 0.0, 0.0, 1.0); + else + fragColor = texture(u_color, seedUV); + } + } + )""""; + } +}; + +// ============================================================================ +// ColorSpreadNode +// ============================================================================ +void ColorSpreadNode::init() +{ + this->title = "Spread"; + + this->addInput("color"); + this->addInput("mask"); + + this->addFloatProp("threshold", "Threshold", 0.5, 0.0, 1.0, 0.01); + this->addFloatProp("distance", "Distance", 1.0, 0.0, 1.0, 0.01); +} + +std::shared_ptr ColorSpreadNode::createRenderer() +{ + return std::make_shared(); +} + +std::shared_ptr ColorSpreadNode::createRenderData() +{ + auto data = std::make_shared(); + + if (auto* p = dynamic_cast(getProp("threshold"))) + data->threshold = static_cast(p->value); + if (auto* p = dynamic_cast(getProp("distance"))) + data->distance = static_cast(p->value); + + return data; +} diff --git a/src/texturelab/libraries/v3/swirl.cpp b/src/texturelab/libraries/v3/swirl.cpp new file mode 100644 index 00000000..e66c4486 --- /dev/null +++ b/src/texturelab/libraries/v3/swirl.cpp @@ -0,0 +1,36 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +void SwirlNode::init() +{ + this->title = "Swirl"; + this->addInput("image"); + + this->addFloatProp("radius", "Radius", 0.7, 0.0, 1.0, 0.01); + this->addFloatProp("angle", "Angle", 90.0, 0.0, 360.0, 0.1); + this->addFloatProp("centerX", "Center X", 0.5, 0.0, 1.0, 0.01); + this->addFloatProp("centerY", "Center Y", 0.5, 0.0, 1.0, 0.01); + + auto source = R""""( + vec4 process(vec2 uv) + { + if (!image_connected) + return vec4(0.0, 0.0, 0.0, 1.0); + + vec2 center = vec2(prop_centerX, prop_centerY); + vec2 delta = uv - center; + float len = length(delta); + float effectAngle = radians(prop_angle); + + float swirlAmount = effectAngle * smoothstep(prop_radius, 0.0, len); + float a = atan(delta.y, delta.x) + swirlAmount; + + vec2 swirlUV = center + vec2(cos(a), sin(a)) * len; + + return texture(image, swirlUV); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/toongradient.cpp b/src/texturelab/libraries/v3/toongradient.cpp new file mode 100644 index 00000000..dde1d002 --- /dev/null +++ b/src/texturelab/libraries/v3/toongradient.cpp @@ -0,0 +1,48 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// Toon / posterised gradient — remaps greyscale input to a stepped, +// band-mapped gradient. Each band maps to a colour stop in the gradient +// prop, giving crisp toon-shading zones while preserving artist colour +// control. Critical for R&C-style roughness (3-band) and albedo (4-6 band) +// maps that read as stylised even up close. +void ToonGradientNode::init() +{ + this->title = "Toon Gradient"; + + this->addInput("image"); + + this->addIntProp ("bands", "Bands", 4, 2, 16, 1); + this->addFloatProp("softness", "Edge Softness", 0.05, 0.0, 0.5, 0.01); + this->addGradientProp("gradient", "Gradient", Gradient::defaultGradient()); + + auto source = R""""( + vec4 process(vec2 uv) + { + if (!image_connected) + return vec4(0.0, 0.0, 0.0, 1.0); + + float input = texture(image, uv).r; + float bands = float(prop_bands); + + // Quantise to N bands + float bandIdx = floor(input * bands); + float bandFract = fract(input * bands); + + // Soft transition between bands using smoothstep on the intra-band fraction + float soft = smoothstep(0.0, prop_softness * bands, bandFract) + * (1.0 - smoothstep((1.0 - prop_softness) * bands, + bands, bandFract + bandIdx * 1.0)); + + // Map to [0,1] for gradient sampling + float t = (bandIdx + clamp(soft, 0.0, 1.0)) / bands; + t = clamp(t, 0.0, 1.0); + + vec3 col = sampleGradient(prop_gradient, t); + return vec4(col, 1.0); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/truchet.cpp b/src/texturelab/libraries/v3/truchet.cpp new file mode 100644 index 00000000..fc344826 --- /dev/null +++ b/src/texturelab/libraries/v3/truchet.cpp @@ -0,0 +1,98 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// Smith & Bouvier, "Truchet Tilings Revisited," The Mathematical Intelligencer 1987 +// https://iquilezles.org/articles/truchet/ +// Truchet tile patterns — per-cell randomly oriented arcs or diagonal lines +// that form continuous curved traces across the grid. Used in sci-fi surface +// tech patterns, circuit board traces and alien architecture detail. +// +// Scale is an integer (cells per UV tile), which combined with the Truchet +// edge-midpoint property guarantees seamless UV tiling: every cell edge +// crosses curves at the same midpoint, so adjacent tiles always connect +// smoothly regardless of per-cell orientation. +void TruchetNode::init() +{ + this->title = "Truchet"; + + this->addIntProp ("scale", "Scale", 8, 2, 32, 1); + this->addFloatProp("line_width", "Line Width", 0.08, 0.01, 0.4, 0.01); + + auto variantProp = this->addEnumProp("variant", "Variant", + {"Arc", "Diagonal", "Diagonal Mirrored"}); + variantProp->index = 0; + + auto source = R""""( + #define VARIANT_ARC 0 + #define VARIANT_DIAG 1 + #define VARIANT_DIAGMIR 2 + + // Signed distance from a line segment for anti-aliased lines + float lineSDF(vec2 p, vec2 a, vec2 b) { + vec2 ab = b - a; + vec2 ap = p - a; + float t = clamp(dot(ap, ab) / dot(ab, ab), 0.0, 1.0); + return length(ap - ab * t); + } + + vec4 process(vec2 uv) + { + vec2 scaled = uv * float(prop_scale); + vec2 cell = floor(scaled); + vec2 local = fract(scaled) - 0.5; // centred at (0,0) in [-0.5, 0.5] + + // Random orientation per cell (0 or 1), driven by engine seed + float r = hash12(cell + vec2(_seed * 0.179, _seed * 0.413)); + float orient = step(0.5, r); // 0.0 or 1.0 + + float d = 1.0; + float lw = prop_line_width * 0.5; + float radius = 0.5; + + if (prop_variant == VARIANT_ARC) { + // Two quarter-circle arcs connecting midpoints of adjacent edges + float d1, d2; + if (orient < 0.5) { + d1 = abs(length(local - vec2(-0.5, -0.5)) - radius); + d2 = abs(length(local - vec2( 0.5, 0.5)) - radius); + } else { + d1 = abs(length(local - vec2( 0.5, -0.5)) - radius); + d2 = abs(length(local - vec2(-0.5, 0.5)) - radius); + } + d = min(d1, d2); + } else if (prop_variant == VARIANT_DIAG) { + // Midpoint-to-midpoint diagonals (Smith-Truchet variant). + // Two short segments connect adjacent edge midpoints; every + // cell touches all 4 edge midpoints regardless of orientation, + // so adjacent cells always connect smoothly. + float d1, d2; + if (orient < 0.5) { + // Upper-left and lower-right segments + d1 = lineSDF(local, vec2(-0.5, 0.0), vec2(0.0, 0.5)); + d2 = lineSDF(local, vec2( 0.5, 0.0), vec2(0.0, -0.5)); + } else { + // Lower-left and upper-right segments (mirror) + d1 = lineSDF(local, vec2(-0.5, 0.0), vec2(0.0, -0.5)); + d2 = lineSDF(local, vec2( 0.5, 0.0), vec2(0.0, 0.5)); + } + d = min(d1, d2); + } else { + // Two corner-to-corner diagonals forming an X in every cell. + // The X always touches all 4 corners of the cell, so adjacent + // cells' diagonals always meet at the shared corner. + float d1 = lineSDF(local, vec2(-0.5, -0.5), vec2( 0.5, 0.5)); + float d2 = lineSDF(local, vec2( 0.5, -0.5), vec2(-0.5, 0.5)); + d = min(d1, d2); + } + + // Anti-aliased line with smoothstep + float aa = fwidth(d); + float line = 1.0 - smoothstep(lw - aa, lw + aa, d); + + return vec4(vec3(line), 1.0); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/vibrance.cpp b/src/texturelab/libraries/v3/vibrance.cpp new file mode 100644 index 00000000..f8737a04 --- /dev/null +++ b/src/texturelab/libraries/v3/vibrance.cpp @@ -0,0 +1,50 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// https://en.wikipedia.org/wiki/Colorfulness +// Vibrance selectively boosts the saturation of low-saturation pixels while +// protecting already-saturated hues from over-saturation clipping. +// Prefer Vibrance over raw Saturation for artist-safe color enhancement. +void VibranceNode::init() +{ + this->title = "Vibrance"; + + this->addInput("image"); + + this->addFloatProp("vibrance", "Vibrance", 0.3, -1.0, 1.0, 0.05); + this->addFloatProp("saturation", "Saturation", 0.0, -1.0, 1.0, 0.05); + + auto source = R""""( + vec4 process(vec2 uv) + { + if (!image_connected) + return vec4(0.0, 0.0, 0.0, 1.0); + + vec4 col = texture(image, uv); + float lum = dot(col.rgb, vec3(0.2126, 0.7152, 0.0722)); + vec3 gray = vec3(lum); + + // --- Uniform saturation (applied first) --- + vec3 saturated = mix(gray, col.rgb, 1.0 + prop_saturation); + + // --- Vibrance: pixel's current saturation range [0..1] --- + float maxC = max(saturated.r, max(saturated.g, saturated.b)); + float minC = min(saturated.r, min(saturated.g, saturated.b)); + float sat = maxC - minC; // 0 = fully gray, 1 = fully saturated + + // Protection factor: low-sat pixels get most boost, saturated pixels get none + float protection = 1.0 - sat; + + // Apply vibrance modulated by protection factor + float boostLum = dot(saturated, vec3(0.2126, 0.7152, 0.0722)); + vec3 boostGray = vec3(boostLum); + vec3 result = mix(boostGray, saturated, + 1.0 + prop_vibrance * protection); + + return vec4(clamp(result, 0.0, 1.0), col.a); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/libraries/v3/voronoifractal.cpp b/src/texturelab/libraries/v3/voronoifractal.cpp new file mode 100644 index 00000000..f4ae6b9e --- /dev/null +++ b/src/texturelab/libraries/v3/voronoifractal.cpp @@ -0,0 +1,113 @@ +#include "../../models.h" +#include "../../props.h" +#include "../libv3.h" + +// Worley, "A Cellular Texture Basis Function," SIGGRAPH 1996 +// https://iquilezles.org/articles/voronoilines/ +// Multi-octave Worley noise (fractal Voronoi). Each octave doubles the +// frequency and halves the amplitude (Lacunarity / Gain). +// Output modes: F1 = distance to nearest cell (soft blobs), +// F2 = second nearest, F2-F1 = cell edges only (crack lines), +// F1+F2 = softer combined cells. +// +// Seamless tiling: each octave's frequency is snapped to the nearest integer +// grid size, and cell hashes use mod()-wrapped indices. This makes the +// pattern repeat exactly across UV tile boundaries. Frequencies are +// effectively rounded — at small Scale values (1-3) you may notice slight +// snapping, but the pattern tiles perfectly at every setting. +void VoronoiFractalNode::init() +{ + this->title = "Voronoi Fractal"; + + this->addFloatProp("scale", "Scale", 4.0, 0.5, 16.0, 0.5); + this->addIntProp ("octaves", "Octaves", 4, 1, 8, 1); + this->addFloatProp("lacunarity", "Lacunarity", 2.0, 1.5, 4.0, 0.1); + this->addFloatProp("gain", "Gain", 0.5, 0.2, 0.8, 0.05); + + auto distProp = this->addEnumProp("distance", "Distance Func", + {"Euclidean", "Manhattan", "Chebyshev"}); + distProp->index = 0; + + auto outProp = this->addEnumProp("output_mode", "Output", + {"F1", "F2", "F2-F1", "F1+F2"}); + outProp->index = 0; + + auto source = R""""( + #define DIST_EUCLIDEAN 0 + #define DIST_MANHATTAN 1 + #define DIST_CHEBYSHEV 2 + #define OUT_F1 0 + #define OUT_F2 1 + #define OUT_F2F1 2 + #define OUT_F1F2 3 + + // Cell point at index `cellIdx` on a periodic grid of size `gridSize`. + // The hash uses mod()-wrapped indices so cells at the tile boundary + // (e.g. cell 0 and cell gridSize) share the same random offset — + // the prerequisite for seamless tiling. + vec2 cellPoint(vec2 cellIdx, float gridSize) { + vec2 wrapped = mod(cellIdx, vec2(gridSize)); + return cellIdx + hash22(wrapped + vec2(_seed * 0.193, _seed * 0.457)); + } + + float cellDist(vec2 a, vec2 b) { + vec2 d = abs(a - b); + if (prop_distance == DIST_MANHATTAN) return d.x + d.y; + if (prop_distance == DIST_CHEBYSHEV) return max(d.x, d.y); + return length(a - b); // Euclidean + } + + // Returns (F1, F2) for UV sampled on an integer-sized grid. + vec2 voronoi(vec2 uv, float gridSize) { + vec2 sampleUV = uv * gridSize; + vec2 cell = floor(sampleUV); + float f1 = 1e9, f2 = 1e9; + + for (int x = -2; x <= 2; x++) { + for (int y = -2; y <= 2; y++) { + vec2 nb = vec2(float(x), float(y)); + vec2 pt = cellPoint(cell + nb, gridSize); + float d = cellDist(sampleUV, pt); + if (d < f1) { f2 = f1; f1 = d; } + else if (d < f2) { f2 = d; } + } + } + // Normalise: in Euclidean space max F1 for uniform random points ≈ 0.67 + return vec2(f1, f2) / 0.67; + } + + vec4 process(vec2 uv) + { + float value = 0.0; + float amplitude = 1.0; + float freq = prop_scale; + float totalAmp = 0.0; + + for (int i = 0; i < prop_octaves; i++) { + // Snap frequency to integer grid size for seamless tiling + float gridSize = max(1.0, floor(freq + 0.5)); + vec2 f = voronoi(uv, gridSize); + + float octaveVal = 0.0; + if (prop_output_mode == OUT_F1) + octaveVal = f.x; + else if (prop_output_mode == OUT_F2) + octaveVal = f.y; + else if (prop_output_mode == OUT_F2F1) + octaveVal = clamp(f.y - f.x, 0.0, 1.0); + else + octaveVal = clamp((f.x + f.y) * 0.5, 0.0, 1.0); + + value += octaveVal * amplitude; + totalAmp += amplitude; + freq *= prop_lacunarity; + amplitude *= prop_gain; + } + + float result = clamp(value / totalAmp, 0.0, 1.0); + return vec4(vec3(result), 1.0); + } + )""""; + + this->setShaderSource(source); +} diff --git a/src/texturelab/main.cpp b/src/texturelab/main.cpp index 6cfd2fec..078de071 100644 --- a/src/texturelab/main.cpp +++ b/src/texturelab/main.cpp @@ -1,7 +1,13 @@ #include "mainwindow.h" +#include "telemetry.h" +#include "version.h" #include +#include #include +#include + +#include // Hints that a dedicated GPU should be used whenever possible // https://stackoverflow.com/a/39047129/991834 @@ -12,22 +18,103 @@ __declspec(dllexport) int AmdPowerXpressRequestHighPerformance = 1; } #endif +// Forward qWarning/qCritical as Sentry breadcrumbs; qFatal as a captured event +// so we get context before Crashpad's abort handler fires. +static void qtMessageHandler(QtMsgType type, const QMessageLogContext& /*ctx*/, + const QString& msg) +{ + switch (type) { + case QtDebugMsg: + break; + case QtInfoMsg: + Telemetry::breadcrumb("qt.info", msg.toStdString()); + break; + case QtWarningMsg: + Telemetry::breadcrumb("qt.warning", msg.toStdString()); + break; + case QtCriticalMsg: + Telemetry::breadcrumb("qt.critical", msg.toStdString()); + break; + case QtFatalMsg: + Telemetry::captureException("qFatal: " + msg.toStdString()); + // Allow default abort() so Crashpad captures the minidump + abort(); + } +} + +#if defined(_MSC_VER) +#define TL_NOINLINE __declspec(noinline) +#else +#define TL_NOINLINE __attribute__((noinline)) +#endif + +// Deliberately dereference a null pointer so Crashpad captures a minidump. +// Kept in a named, non-inlined function so the symbolicated Sentry stack trace +// shows a recognizable frame. Triggered only via the --sentry-crash-test flag; +// remove this hook once symbolication is confirmed in production. +TL_NOINLINE static void sentryCrashTest() +{ + volatile int* p = nullptr; + *p = 0xC0FFEE; +} + int main(int argc, char* argv[]) { + // Read opt-out before constructing QApplication so we can use QSettings + // with an explicit scope (no org/app name set yet). + QSettings settings(QSettings::UserScope, "texturelab", "texturelab"); + bool crashReportingEnabled = + settings.value("crashReporting", true).toBool(); + + // Init Sentry before QApplication; resolves paths via Qt helpers after + // QCoreApplication is available (handler_path needs applicationDirPath). + // We pass a temporary QCoreApplication for path resolution, then tear it + // down before the real QApplication is constructed. + // + // Actually: sentry_options_set_handler_path / database_path only need the + // strings — we can derive them from argv[0] or defer to after QApplication. + // Simplest: init Sentry after QApplication (Crashpad handler is separate + // process anyway so it doesn't need QApplication to be alive). + QCoreApplication::setAttribute(Qt::AA_ShareOpenGLContexts); QCoreApplication::setAttribute(Qt::AA_UseDesktopOpenGL); QSurfaceFormat format; format.setProfile(QSurfaceFormat::CoreProfile); format.setVersion(3, 2); - // format.setColorSpace(QSurfaceFormat::sRGBColorSpace); QSurfaceFormat::setDefaultFormat(format); QApplication a(argc, argv); - MainWindow w; + a.setOrganizationName("texturelab"); + a.setApplicationName("texturelab"); + a.setApplicationVersion(QString(TEXTURELAB_VERSION) + "+" + TEXTURELAB_BUILD_HASH); + // Now applicationDirPath() is valid — init Sentry + Telemetry::init(crashReportingEnabled); + + // Crash-test hook for verifying Sentry symbolication end-to-end. + // Must run after Telemetry::init so the crash handler is armed. + for (int i = 1; i < argc; ++i) { + if (std::strcmp(argv[i], "--sentry-crash-test") == 0) { + // Give the SDK's network transport a moment to spin up before we + // crash. Crashpad (Win/Linux) uploads out-of-process so this isn't + // needed there, but the macOS inproc backend must send the event + // synchronously from the dying process — an instant crash at + // startup dies before the transport is ready. A real crash happens + // after the app has been running, so this warm-up is representative. + QThread::sleep(4); + sentryCrashTest(); + } + } + + // Install message handler after Sentry is up so breadcrumbs are captured + qInstallMessageHandler(qtMessageHandler); + + MainWindow w; w.show(); w.showMaximized(); - return a.exec(); + int ret = a.exec(); + Telemetry::close(); + return ret; } diff --git a/src/texturelab/mainwindow.cpp b/src/texturelab/mainwindow.cpp index ddebb137..ea0fb1cc 100644 --- a/src/texturelab/mainwindow.cpp +++ b/src/texturelab/mainwindow.cpp @@ -3,16 +3,26 @@ #include #include +#include #include +#include #include +#include +#include +#include #include #include #include #include #include +#include #include #include +#include #include +#include +#include +#include #include #include @@ -20,6 +30,9 @@ #include "DockSplitter.h" #include "exporter.h" +#include "telemetry.h" +#include "undo/undocommands.h" +#include "widgets/aboutdialog.h" #include "widgets/exportdialog.h" #include "widgets/graphwidget.h" #include "widgets/librarywidget.h" @@ -30,14 +43,21 @@ #include "viewer3d.h" #include "models.h" +#include "libraries/libraryversionmigrator.h" +#include "libraries/libversion.h" #include "project.h" #include "props.h" #include "graphics/texturerenderer.h" +#include "graph/scene.h" MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { resize(1280, 720); + setAcceptDrops(true); + + undoStack = new QUndoStack(this); + connect(undoStack, &QUndoStack::cleanChanged, this, &MainWindow::onCleanChanged); this->setupMenus(); this->setupToolbar(); @@ -45,6 +65,23 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) this->renderer = nullptr; this->exportDialog = nullptr; + statusLabel = new QLabel("Ready"); + progressBar = new QProgressBar(); + progressBar->setRange(0, 1); + progressBar->setValue(1); + progressBar->setFixedWidth(180); + progressBar->setTextVisible(false); + + auto* statusWidget = new QWidget(); + auto* statusLayout = new QHBoxLayout(statusWidget); + // statusLayout->setContentsMargins(4, 0, 4, 0); + statusLayout->setContentsMargins(0, 0, 0, 0); + statusLayout->setSpacing(6); + statusLayout->addStretch(); + statusLayout->addWidget(statusLabel, 0, Qt::AlignVCenter); + statusLayout->addWidget(progressBar, 0, Qt::AlignVCenter); + statusBar()->addWidget(statusWidget, 1); + this->dockManager = new ads::CDockManager(this); this->setupDocks(); @@ -72,6 +109,36 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) } }); + connect(this->graphWidget, &GraphWidget::frameSelectionChanged, + [this](const FramePtr& frame) { + if (!!frame) { + this->propWidget->setSelectedFrame(frame); + } + else { + this->propWidget->clearSelection(); + } + }); + + connect(this->graphWidget, &GraphWidget::commentSelectionChanged, + [this](const CommentPtr& comment) { + if (!!comment) { + this->propWidget->setSelectedComment(comment); + } + else { + this->propWidget->clearSelection(); + } + }); + + connect(this->propWidget, &PropertiesWidget::framePropertyChanged, + [this](const FramePtr& frame) { + this->graphWidget->syncFrameToScene(frame); + }); + + connect(this->propWidget, &PropertiesWidget::commentPropertyChanged, + [this](const CommentPtr& comment) { + this->graphWidget->syncCommentToScene(comment); + }); + connect(this->propWidget, &PropertiesWidget::propertyUpdated, [this](const QString& name, const QVariant& value) { if (this->renderer && !!this->project) { @@ -84,35 +151,41 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) connect(this->propWidget, &PropertiesWidget::textureChannelUpdated, [this](const TextureChannel& name, const TextureNodePtr& node) { - if (this->renderer && !!this->project) { - this->renderer->update(); - } + if (!this->project) + return; - if (!!this->project) { - if (name == TextureChannel::None) { - // Find and remove any channel that points to this node - auto it = this->project->textureChannels.begin(); - while (it != this->project->textureChannels.end()) { - if (it.value() == node->id) { - it = this->project->textureChannels.erase(it); - } - else { - ++it; - } + auto syncViewer = [this]() { + this->passTextureChannelsToViewer3D(); + this->syncChannelLabelsToScene(); + this->view3DWidget->reRender(); + if (this->renderer) + this->renderer->update(); + }; + + if (name == TextureChannel::None) { + // Unassign this node from whichever channel it's in + for (auto ch : this->project->textureChannels.keys()) { + if (this->project->textureChannels[ch] == node->id) { + QString oldNodeId = node->id; + // Apply immediately, then push command (first-redo no-op) + this->project->textureChannels.remove(ch); + syncViewer(); + if (undoStack) + undoStack->push(new TextureChannelAssignCommand( + this->project, ch, oldNodeId, "", syncViewer)); + break; } } - else { - - this->project->textureChannels[name] = node->id; - } - - // assign channel to viewer - // do this crudely by just reassigning all node textures - this->passTextureChannelsToViewer3D(); } - - // this->view3DWidget->update(); - this->view3DWidget->reRender(); + else { + QString oldNodeId = this->project->textureChannels.value(name, ""); + // Apply immediately, then push command (first-redo no-op) + this->project->textureChannels[name] = node->id; + syncViewer(); + if (undoStack) + undoStack->push(new TextureChannelAssignCommand( + this->project, name, oldNodeId, node->id, syncViewer)); + } }); // set default empty project @@ -167,17 +240,62 @@ void MainWindow::passTextureChannelsToViewer3D() case TextureChannel::Roughness: viewer->setRoughnessTexture(node->textureId()); break; + case TextureChannel::Height: + viewer->setHeightTexture(node->textureId()); + break; + case TextureChannel::AO: + viewer->setAoTexture(node->textureId()); + break; + case TextureChannel::Alpha: + viewer->setAlphaTexture(node->textureId()); + break; default: break; } } } +static QString channelName(TextureChannel ch) +{ + switch (ch) { + case TextureChannel::Albedo: return "Albedo"; + case TextureChannel::Normal: return "Normal"; + case TextureChannel::Metalness: return "Metalness"; + case TextureChannel::Roughness: return "Roughness"; + case TextureChannel::Height: return "Height"; + case TextureChannel::Alpha: return "Alpha"; + case TextureChannel::AO: return "AO"; + default: return ""; + } +} + +void MainWindow::syncChannelLabelsToScene() +{ + if (!project || !graphWidget->scene) + return; + + // clear all labels first + for (auto& node : graphWidget->scene->nodes) + node->setChannel(""); + + // set labels from project state + for (auto ch : project->textureChannels.keys()) { + auto nodeId = project->textureChannels[ch]; + auto sceneNode = graphWidget->scene->nodes.value(nodeId); + if (sceneNode) + sceneNode->setChannel(channelName(ch)); + } +} + void MainWindow::setProject(TextureProjectPtr project) { + // Clear widget state from the old project + this->view2DWidget->clearSelection(); + this->view3DWidget->viewer->clearTextures(); + this->view3DWidget->reRender(); + // Clean up old renderer before creating new one if (this->renderer) { - // Clear references to old renderer in widgets this->graphWidget->setTextureRenderer(nullptr); this->view2DWidget->setTextureRenderer(nullptr); @@ -187,16 +305,34 @@ void MainWindow::setProject(TextureProjectPtr project) this->project = project; this->graphWidget->setTextureProject(project); + this->syncChannelLabelsToScene(); this->libraryWidget->setLibrary(project->library); + this->libraryWidget->setLibraryVersion( + project->libraryVersion, + project->libraryVersion == libVersionToString(currentLibVersion())); this->propWidget->clearSelection(); this->propWidget->setProject(project); + this->propWidget->setScene(this->graphWidget->scene); renderer = new TextureRenderer(); renderer->setProject(project); this->graphWidget->setTextureRenderer(renderer); this->view2DWidget->setTextureRenderer(renderer); + connect(renderer, &TextureRenderer::renderProgress, + [this](int clean, int total) { + progressBar->setMaximum(total == 0 ? 1 : total); + progressBar->setValue(total == 0 ? 1 : clean); + if (total == 0 || clean == total) { + statusLabel->setText("Ready"); + } + else { + statusLabel->setText( + QString("Rendering %1 / %2").arg(clean).arg(total)); + } + }); + // Update view3D textures when a node's texture is updated connect(renderer, &TextureRenderer::thumbnailGenerated, [this](const QString& nodeId, GLuint texId, const QPixmap&) { @@ -222,6 +358,12 @@ void MainWindow::setProject(TextureProjectPtr project) case TextureChannel::Height: viewer->setHeightTexture(texId); break; + case TextureChannel::AO: + viewer->setAoTexture(texId); + break; + case TextureChannel::Alpha: + viewer->setAlphaTexture(texId); + break; default: break; } @@ -233,8 +375,8 @@ void MainWindow::setProject(TextureProjectPtr project) renderer->update(); - // Update window title with project name setWindowTitle(project->name + " - TextureLab"); + undoStack->clear(); } void MainWindow::setupMenus() @@ -243,17 +385,27 @@ void MainWindow::setupMenus() fileMenu->addAction("Open Project", [=]() { this->openProject(); }); fileMenu->addAction("New Project", [=]() { this->newProject(); }); fileMenu->addSeparator(); - fileMenu->addAction("Save", []() {}); - fileMenu->addAction("Save As...", []() {}); + fileMenu->addAction("Save", [=]() { this->saveProject(); }); + fileMenu->addAction("Save As...", [=]() { this->saveProjectAs(); }); + fileMenu->addSeparator(); + + recentFilesMenu = fileMenu->addMenu("Open Recent"); + connect(recentFilesMenu, &QMenu::aboutToShow, this, + &MainWindow::updateRecentFilesMenu); + fileMenu->addSeparator(); fileMenu->addAction("Edit", []() {}); auto editMenu = this->menuBar()->addMenu("Edit"); - editMenu->addAction("Undo", []() {}); - editMenu->addAction("Redo", []() {}); - editMenu->addAction("Cut", []() {}); - editMenu->addAction("Copy", []() {}); - editMenu->addAction("Paste", []() {}); + auto undoAction = undoStack->createUndoAction(this, tr("Undo")); + undoAction->setShortcut(QKeySequence::Undo); + editMenu->addAction(undoAction); + auto redoAction = undoStack->createRedoAction(this, tr("Redo")); + redoAction->setShortcut(QKeySequence::Redo); + editMenu->addAction(redoAction); + editMenu->addAction("Cut", [=]() { graphWidget->executeCut(); }); + editMenu->addAction("Copy", [=]() { graphWidget->executeCopy(); }); + editMenu->addAction("Paste", [=]() { graphWidget->executePaste(); }); auto examplesMenu = this->menuBar()->addMenu("Examples"); @@ -275,23 +427,32 @@ void MainWindow::setupMenus() displayName.replace(".texture", ""); examplesMenu->addAction(displayName, [this, example]() { - QString examplePath = ":examples/" + example; - - // Load the example project - auto project = Project::loadTexture(examplePath); - - // Set project name from filename + if (!promptSaveIfDirty()) + return; + auto project = Project::loadTexture(":examples/" + example); QString projectName = example; projectName.replace(".texture", ""); project->name = projectName; - setProject(project); }); } auto optionsMenu = this->menuBar()->addMenu("Help"); - optionsMenu->addAction("Documentation", []() {}); - optionsMenu->addAction("About", []() {}); + optionsMenu->addAction("About", [this]() { + AboutDialog dialog(this); + dialog.exec(); + }); + + optionsMenu->addSeparator(); + + auto crashReportingAction = optionsMenu->addAction("Send Anonymous Crash Reports"); + crashReportingAction->setCheckable(true); + QSettings settings(QSettings::UserScope, "texturelab", "texturelab"); + crashReportingAction->setChecked(settings.value("crashReporting", true).toBool()); + connect(crashReportingAction, &QAction::toggled, [](bool checked) { + QSettings s(QSettings::UserScope, "texturelab", "texturelab"); + s.setValue("crashReporting", checked); + }); } void MainWindow::setupToolbar() @@ -302,9 +463,9 @@ void MainWindow::setupToolbar() QWidget* spacer = new QWidget(); spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - // undo redo - toolBar->addAction("Undo"); - toolBar->addAction("Redo"); + // undo redo — reuse the same actions wired to the stack + toolBar->addAction(undoStack->createUndoAction(this)); + toolBar->addAction(undoStack->createRedoAction(this)); // spacer toolBar->addWidget(spacer); @@ -365,6 +526,7 @@ void MainWindow::setupDocks() // graph goes in the center this->graphWidget = new GraphWidget(); + this->graphWidget->setUndoStack(undoStack); this->view2DWidget = new View2DWidget(); this->view3DWidget = new View3DWidget(); @@ -374,10 +536,13 @@ void MainWindow::setupDocks() this->view2DWidget, graphArea); this->propWidget = new PropertiesWidget(); + this->propWidget->setUndoStack(undoStack); auto rightArea = addDock("Properties", ads::RightDockWidgetArea, this->propWidget, graphArea); this->libraryWidget = new LibraryWidget(); + connect(this->libraryWidget, &LibraryWidget::upgradeRequested, this, + &MainWindow::upgradeCurrentProjectLibrary); setWidgetRatiosInArea(graphArea, {1.0f / 5, 3.0f / 5, 1.0f / 5}); addDock("3D View", ads::BottomDockWidgetArea, this->view3DWidget, leftArea); @@ -385,6 +550,8 @@ void MainWindow::setupDocks() rightArea); setWidgetRatiosInArea(leftArea, {0.5f, 0.5f}); setWidgetRatiosInArea(rightArea, {0.5f, 0.5f}); + + QTimer::singleShot(0, this, [this]() { graphWidget->setFocus(); }); } ads::CDockAreaWidget* MainWindow::addDock(const QString& title, @@ -405,23 +572,194 @@ ads::CDockAreaWidget* MainWindow::addDock(const QString& title, void MainWindow::openProject() { + if (!promptSaveIfDirty()) + return; + auto filePath = QFileDialog::getOpenFileName(this, "Open Texture File", "", "Texturelab File (*.texture)"); - if (filePath.isNull() || filePath.isEmpty()) { + if (filePath.isNull() || filePath.isEmpty()) + return; + + openProjectFromPath(filePath); +} + +void MainWindow::openProjectFromPath(const QString& filePath) +{ + if (!promptSaveIfDirty()) + return; + + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly)) { + QMessageBox::warning(this, "Open Texture", + "Could not open file:\n" + filePath); return; } + auto json = QJsonDocument::fromJson(file.readAll()).object(); + file.close(); + + LibraryVersionMigrator migrator(json); + if (migrator.needsMigration()) { + QStringList chain; + chain << libVersionToString(migrator.sourceVersion()); + for (auto v : migrator.versionsCrossed()) + chain << libVersionToString(v); + + auto choice = QMessageBox::question( + this, "Upgrade Texture?", + QString("This texture was created with library version %1.\n\n" + "Upgrade it to %2 (%3) to use the latest nodes and " + "improvements? A few node behaviors may change slightly.") + .arg(libVersionToString(migrator.sourceVersion()), + libVersionToString(migrator.targetVersion()), + chain.join(" → ")), + QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); + + if (choice == QMessageBox::Yes) + json = migrator.migrate(); + } - auto project = Project::loadTexture(filePath); + auto project = Project::loadTextureFromJson(json); - // Extract filename without extension QFileInfo fileInfo(filePath); project->name = fileInfo.baseName(); + project->filePath = filePath; + Telemetry::breadcrumb("project", "open: " + fileInfo.baseName().toStdString()); setProject(project); + addToRecentFiles(filePath); +} + +void MainWindow::dragEnterEvent(QDragEnterEvent* event) +{ + if (!event->mimeData()->hasUrls()) { + event->ignore(); + return; + } + + for (const auto& url : event->mimeData()->urls()) { + if (url.isLocalFile() && + url.toLocalFile().endsWith(".texture", Qt::CaseInsensitive)) { + event->acceptProposedAction(); + return; + } + } + event->ignore(); +} + +void MainWindow::dropEvent(QDropEvent* event) +{ + for (const auto& url : event->mimeData()->urls()) { + if (!url.isLocalFile()) + continue; + + auto filePath = url.toLocalFile(); + if (!filePath.endsWith(".texture", Qt::CaseInsensitive)) + continue; + + event->acceptProposedAction(); + openProjectFromPath(filePath); + return; + } + + event->ignore(); } -void MainWindow::newProject() { setProject(TextureProject::createEmpty()); } +void MainWindow::upgradeCurrentProjectLibrary() +{ + if (!this->project) + return; + + // Round-trip through the same JSON the file format uses, so the live + // "Upgrade" button in the Library dock goes through the exact same + // pure-JSON migration path as opening a legacy file does. + auto bytes = Project::saveTexture(this->project); + auto json = QJsonDocument::fromJson(bytes).object(); + + LibraryVersionMigrator migrator(json); + if (!migrator.needsMigration()) + return; + + auto choice = QMessageBox::warning( + this, "Upgrade Library Version?", + "Upgrading the library version is irreversible and clears the " + "undo/redo history for this session.\n\n" + "Save your project (or save a copy) first if you want to keep the " + "ability to go back to the current version.\n\n" + "Continue with the upgrade?", + QMessageBox::Yes | QMessageBox::Cancel, QMessageBox::Cancel); + + if (choice != QMessageBox::Yes) + return; + + auto newProject = Project::loadTextureFromJson(migrator.migrate()); + newProject->name = this->project->name; + newProject->filePath = this->project->filePath; + + setProject(newProject); +} + +void MainWindow::newProject() +{ + if (!promptSaveIfDirty()) + return; + Telemetry::breadcrumb("project", "new project"); + setProject(TextureProject::createEmpty()); +} + +void MainWindow::saveProject() +{ + if (project->filePath.isNull() || project->filePath.isEmpty()) { + QString filePath = QFileDialog::getSaveFileName( + this, "Save Texture...", QString(), "Texturelab File (*.texture)"); + + if (filePath.isNull() || filePath.isEmpty()) { + return; + } + + if (!filePath.endsWith(".texture", Qt::CaseInsensitive)) + filePath += ".texture"; + + project->filePath = filePath; + } + + graphWidget->syncPositionsToModel(); + + Telemetry::breadcrumb("project", "save: " + project->name.toStdString()); + QFile file(project->filePath); + file.open(QIODevice::WriteOnly); + file.write(Project::saveTexture(project)); + file.close(); + undoStack->setClean(); + addToRecentFiles(project->filePath); +} + +void MainWindow::saveProjectAs() +{ + QString filePath = QFileDialog::getSaveFileName( + this, "Save Texture As...", QString(), "Texturelab File (*.texture)"); + + if (filePath.isNull() || filePath.isEmpty()) + return; + + if (!filePath.endsWith(".texture", Qt::CaseInsensitive)) + filePath += ".texture"; + + project->filePath = filePath; + + QFileInfo fileInfo(filePath); + project->name = fileInfo.baseName(); + + graphWidget->syncPositionsToModel(); + + QFile file(project->filePath); + file.open(QIODevice::WriteOnly); + file.write(Project::saveTexture(project)); + file.close(); + undoStack->setClean(); + setWindowTitle(project->name + " - TextureLab"); + addToRecentFiles(project->filePath); +} void MainWindow::showExportDialog() { @@ -474,6 +812,7 @@ void MainWindow::directExport() void MainWindow::handleExport(const QString& destination, const QString& pattern) { + Telemetry::breadcrumb("project", "export to: " + destination.toStdString()); if (!this->project || !this->renderer) { QMessageBox::warning(this, "Export Error", "No project loaded or renderer not initialized."); @@ -588,9 +927,76 @@ void MainWindow::handleExport(const QString& destination, QMessageBox::information(this, "Export", message); } +void MainWindow::addToRecentFiles(const QString& filePath) +{ + QSettings settings; + QStringList files = settings.value("recentFiles").toStringList(); + files.removeAll(filePath); + files.prepend(filePath); + while (files.size() > MaxRecentFiles) + files.removeLast(); + settings.setValue("recentFiles", files); +} + +void MainWindow::updateRecentFilesMenu() +{ + recentFilesMenu->clear(); + + QSettings settings; + QStringList files = settings.value("recentFiles").toStringList(); + + for (const QString& filePath : files) { + QFileInfo info(filePath); + auto action = recentFilesMenu->addAction( + info.fileName(), [this, filePath]() { openProjectFromPath(filePath); }); + action->setToolTip(filePath); + } + + if (files.isEmpty()) + recentFilesMenu->addAction("No recent files")->setEnabled(false); + + recentFilesMenu->addSeparator(); + recentFilesMenu->addAction("Clear Recent Files", + [this]() { QSettings().remove("recentFiles"); }); +} + +void MainWindow::onCleanChanged(bool clean) +{ + if (!project) + return; + QString title = project->name + " - TextureLab"; + setWindowTitle(clean ? title : "*" + title); +} + +bool MainWindow::promptSaveIfDirty() +{ + if (undoStack->isClean()) + return true; + + QString name = project ? project->name : "Untitled"; + auto choice = QMessageBox::question( + this, "Unsaved Changes", + QString("Save changes to \"%1\" before continuing?").arg(name), + QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel); + + if (choice == QMessageBox::Save) { + saveProject(); + return undoStack->isClean(); // false if save was cancelled + } + return choice == QMessageBox::Discard; +} + +void MainWindow::closeEvent(QCloseEvent* event) +{ + if (!promptSaveIfDirty()) { + event->ignore(); + return; + } + event->accept(); +} + MainWindow::~MainWindow() { - // Clean up renderer if (this->renderer) { delete this->renderer; this->renderer = nullptr; diff --git a/src/texturelab/mainwindow.h b/src/texturelab/mainwindow.h index 878dc3ae..cb1418bf 100644 --- a/src/texturelab/mainwindow.h +++ b/src/texturelab/mainwindow.h @@ -2,9 +2,15 @@ #define MAINWINDOW_H #include "DockManager.h" +#include +#include +#include #include +#include +#include #include #include +#include class GraphWidget; class LibraryWidget; @@ -18,6 +24,8 @@ class TextureProject; typedef QSharedPointer TextureProjectPtr; class QToolBar; +class QProgressBar; +class QLabel; class MainWindow : public QMainWindow { Q_OBJECT @@ -29,24 +37,52 @@ class MainWindow : public QMainWindow { void setupToolbar(); void setupMenus(); void setupDocks(); + void closeEvent(QCloseEvent* event) override; + void dragEnterEvent(QDragEnterEvent* event) override; + void dropEvent(QDropEvent* event) override; // menu callbacks void openProject(); + + // Shared by the Open dialog, recent-files menu, and drag-and-drop: + // prompts to save if dirty, reads the file, offers the + // version-upgrade dialog if needed, then loads it. + void openProjectFromPath(const QString& filePath); void newProject(); + void saveProject(); + void saveProjectAs(); void showExportDialog(); void directExport(); void handleExport(const QString& destination, const QString& pattern); void passTextureChannelsToViewer3D(); + void syncChannelLabelsToScene(); void setProject(TextureProjectPtr project); + // Upgrades the currently open project's library in place, via the + // Library dock panel's "Upgrade" button. + void upgradeCurrentProjectLibrary(); + + void addToRecentFiles(const QString& filePath); + void updateRecentFilesMenu(); + + void onCleanChanged(bool clean); + + // Returns false if the user cancelled a "save changes?" dialog. + bool promptSaveIfDirty(); + ads::CDockAreaWidget* addDock(const QString& title, ads::DockWidgetArea area, QWidget* widget, ads::CDockAreaWidget* areaWidget); private: + static constexpr int MaxRecentFiles = 10; + + QUndoStack* undoStack; + ads::CDockManager* dockManager; + QMenu* recentFilesMenu; QToolBar* toolBar; QWidget* editor; @@ -59,6 +95,9 @@ class MainWindow : public QMainWindow { TextureRenderer* renderer; + QProgressBar* progressBar; + QLabel* statusLabel; + TextureProjectPtr project; }; #endif // MAINWINDOW_H diff --git a/src/texturelab/models.cpp b/src/texturelab/models.cpp index b709fdc2..36b30383 100644 --- a/src/texturelab/models.cpp +++ b/src/texturelab/models.cpp @@ -118,7 +118,7 @@ TextureProjectPtr TextureProject::createEmpty(Library* library) if (library != nullptr) project->library = library; else - project->library = createLibraryV2(); + project->library = createLibraryV3(); return TextureProjectPtr(project); } @@ -286,5 +286,18 @@ ImageProp* TextureNode::addImageProp(const QString& name, props[name] = prop; + return prop; +} + +CurveProp* TextureNode::addCurveProp(const QString& name, + const QString& displayName) +{ + auto prop = new CurveProp(); + prop->name = name; + prop->displayName = displayName; + prop->order = props.size(); + + props[name] = prop; + return prop; } \ No newline at end of file diff --git a/src/texturelab/models.h b/src/texturelab/models.h index 130984d9..9ccd6f42 100644 --- a/src/texturelab/models.h +++ b/src/texturelab/models.h @@ -9,6 +9,7 @@ #include #include #include +#include class QOpenGLFramebufferObject; class QOpenGLShaderProgram; @@ -29,6 +30,8 @@ typedef QSharedPointer ConnectionPtr; class Prop; class PropertyGroup; class Library; +class NodeTextureRenderer; +struct NodeRenderData; class IntProp; class FloatProp; @@ -38,6 +41,7 @@ class ColorProp; class StringProp; class GradientProp; class ImageProp; +class CurveProp; enum class PackageFileType { Texture, Image }; @@ -48,7 +52,8 @@ enum class TextureChannel : int { Metalness = 3, Roughness = 4, Height = 5, - Alpha = 6 + Alpha = 6, + AO = 7 }; class ProjectFile { @@ -67,6 +72,7 @@ class TextureProject : public QEnableSharedFromThis { QMap textureChannels; Library* library = nullptr; + QString libraryVersion = "v3"; QMap nodes; QMap connections; @@ -80,6 +86,7 @@ class TextureProject : public QEnableSharedFromThis { QString exportFilePattern = "${project}_${name}"; QString exportDestination = ""; + QString filePath = nullptr; void addNode(const TextureNodePtr& node); @@ -101,6 +108,7 @@ class TextureProject : public QEnableSharedFromThis { class TextureNode : public QEnableSharedFromThis { public: QString id; + QString typeName; QString title; QVector2D pos; @@ -142,6 +150,21 @@ class TextureNode : public QEnableSharedFromThis { void setShaderSource(const QString& source) { shaderSource = source; } + // Override to provide a custom renderer for multi-pass or non-standard + // rendering. Called on the main thread during queueNextNodeToRender(). + // Return nullptr for standard single-pass rendering. + virtual std::shared_ptr createRenderer() + { + return nullptr; + } + + // Override to provide render-time data for the custom renderer. + // Called on the main thread. Must not reference GPU resources. + virtual std::shared_ptr createRenderData() + { + return nullptr; + } + bool isGraphicsResourcesInitialized() { return texture != nullptr && shader != nullptr; @@ -172,6 +195,8 @@ class TextureNode : public QEnableSharedFromThis { const Gradient& defaultVal); ImageProp* addImageProp(const QString& name, const QString& displayName); + + CurveProp* addCurveProp(const QString& name, const QString& displayName); }; class Comment : public QEnableSharedFromThis { @@ -185,6 +210,7 @@ class Frame : public QEnableSharedFromThis { public: QString id; QString text; + QColor color = QColor(25, 0, 51); QVector2D pos; QVector2D size; diff --git a/src/texturelab/project.cpp b/src/texturelab/project.cpp index 2cd05537..27c7230f 100644 --- a/src/texturelab/project.cpp +++ b/src/texturelab/project.cpp @@ -1,5 +1,7 @@ #include "project.h" #include "libraries/library.h" +#include "libraries/libraryversionmigrator.h" +#include "libraries/libversion.h" #include "props.h" #include #include @@ -12,7 +14,7 @@ TextureProjectPtr Project::loadTexture(QString path) QFile file(path); file.open(QIODevice::ReadOnly); QJsonParseError error; - auto json = QJsonDocument::fromJson(file.readAll(), &error); + auto doc = QJsonDocument::fromJson(file.readAll(), &error); file.close(); if (error.error) { @@ -20,13 +22,21 @@ TextureProjectPtr Project::loadTexture(QString path) return TextureProjectPtr(nullptr); } - TextureProjectPtr texture(new TextureProject()); + return Project::loadTextureFromJson(doc.object()); +} - // qDebug() << json["libraryVersion"].toString(); +TextureProjectPtr Project::loadTextureFromJson(QJsonObject json) +{ + TextureProjectPtr texture(new TextureProject()); - // create library from version - // Library *lib = new LibraryV1(); - Library* lib = createLibraryV2(); + // Pick the library matching this JSON's own version, so legacy + // typeNames (e.g. "floodfill", "bevel", "perlin3d") that no longer + // exist in the current library still resolve instead of crashing. + // Callers that want the file upgraded should run it through + // LibraryVersionMigrator first and pass in the migrated JSON. + LibVersion version = LibraryVersionMigrator(json).sourceVersion(); + Library* lib = createLibraryForVersion(version); + texture->libraryVersion = libVersionToString(version); // scene objects auto sceneObj = json["scene"].toObject(); @@ -110,6 +120,9 @@ TextureProjectPtr Project::loadTexture(QString path) frame->pos = QVector2D(obj["x"].toDouble(), obj["y"].toDouble()); frame->size = QVector2D(obj["width"].toDouble(300), obj["height"].toDouble(200)); + auto colorStr = obj["color"].toString(); + if (!colorStr.isEmpty()) + frame->color = QColor(colorStr); texture->frames[frame->id] = frame; } @@ -137,6 +150,8 @@ TextureProjectPtr Project::loadTexture(QString path) channel = TextureChannel::Height; else if (key == "alpha") channel = TextureChannel::Alpha; + else if (key == "ao") + channel = TextureChannel::AO; if (channel != TextureChannel::None) { auto nodeId = channels[key].toString(); @@ -148,4 +163,110 @@ TextureProjectPtr Project::loadTexture(QString path) texture->library = lib; return texture; +} + +QByteArray Project::saveTexture(TextureProjectPtr texture) +{ + QJsonObject json; + + // nodes + QJsonArray nodeArray; + for (auto& node : texture->nodes) { + QJsonObject nodeDef; + nodeDef["typeName"] = node->typeName; + nodeDef["id"] = node->id; + nodeDef["exportName"] = node->exportName; + nodeDef["randomSeed"] = (double)node->randomSeed; + + QJsonObject propObj; + for (auto key : node->props.keys()) { + propObj[key] = node->props[key]->toJsonValue(); + } + nodeDef["properties"] = propObj; + + nodeArray.append(nodeDef); + } + json["nodes"] = nodeArray; + + // scene (node positions, comments, frames) + QJsonObject sceneObj; + + QJsonObject sceneNodesObj; + for (auto& node : texture->nodes) { + QJsonObject posObj; + posObj["x"] = node->pos.x(); + posObj["y"] = node->pos.y(); + sceneNodesObj[node->id] = posObj; + } + sceneObj["nodes"] = sceneNodesObj; + + QJsonArray commentArray; + for (auto& comment : texture->comments) { + QJsonObject obj; + obj["id"] = comment->id; + obj["text"] = comment->text; + obj["x"] = comment->pos.x(); + obj["y"] = comment->pos.y(); + commentArray.append(obj); + } + sceneObj["comments"] = commentArray; + + QJsonArray frameArray; + for (auto& frame : texture->frames) { + QJsonObject obj; + obj["id"] = frame->id; + obj["title"] = frame->text; + obj["color"] = frame->color.name(QColor::HexRgb); + obj["x"] = frame->pos.x(); + obj["y"] = frame->pos.y(); + obj["width"] = frame->size.x(); + obj["height"] = frame->size.y(); + frameArray.append(obj); + } + sceneObj["frames"] = frameArray; + + json["scene"] = sceneObj; + + // connections + QJsonArray conArray; + for (auto& con : texture->connections) { + QJsonObject conObj; + conObj["leftNodeId"] = con->leftNode->id; + conObj["rightNodeId"] = con->rightNode->id; + conObj["rightNodeInput"] = con->rightNodeInputName; + conArray.append(conObj); + } + json["connections"] = conArray; + + // library version this project's nodes/properties were saved against + json["libraryVersion"] = texture->libraryVersion; + + // export settings + QJsonObject exportObj; + exportObj["filePattern"] = texture->exportFilePattern; + exportObj["destination"] = texture->exportDestination; + json["export"] = exportObj; + + // editor texture channels + QJsonObject channelsObj; + for (auto it = texture->textureChannels.begin(); + it != texture->textureChannels.end(); ++it) { + QString key; + switch (it.key()) { + case TextureChannel::Albedo: key = "albedo"; break; + case TextureChannel::Normal: key = "normal"; break; + case TextureChannel::Metalness: key = "metalness"; break; + case TextureChannel::Roughness: key = "roughness"; break; + case TextureChannel::Height: key = "height"; break; + case TextureChannel::Alpha: key = "alpha"; break; + case TextureChannel::AO: key = "ao"; break; + default: continue; + } + channelsObj[key] = it.value(); + } + QJsonObject editorObj; + editorObj["textureChannels"] = channelsObj; + json["editor"] = editorObj; + + return QJsonDocument(json).toJson(); } \ No newline at end of file diff --git a/src/texturelab/project.h b/src/texturelab/project.h index 445fba83..9c21ca1d 100644 --- a/src/texturelab/project.h +++ b/src/texturelab/project.h @@ -1,9 +1,17 @@ #pragma once #include "models.h" +#include class Project { public: static TextureProjectPtr loadTexture(QString path); + + // Builds a project from already-parsed (and possibly migrated) JSON. + // Used directly by MainWindow when it has run the JSON through + // LibraryVersionMigrator before constructing any node/library objects. + static TextureProjectPtr loadTextureFromJson(QJsonObject json); + + static QByteArray saveTexture(TextureProjectPtr texture); }; \ No newline at end of file diff --git a/src/texturelab/props.h b/src/texturelab/props.h index 42adf055..eb63308d 100644 --- a/src/texturelab/props.h +++ b/src/texturelab/props.h @@ -1,5 +1,6 @@ #pragma once +#include "curve.h" #include "../colorpicker/gradient.h" #include #include @@ -30,7 +31,8 @@ class PropType { Enum, String, Gradient, - Image + Image, + Curve }; static QString toString(Value propType); @@ -506,6 +508,50 @@ class GradientProp : public Prop { } }; +class CurveProp : public Prop { +public: + Curve value; // default: linear identity + + CurveProp() : Prop() { type = PropType::Curve; } + + Prop* clone() const override + { + auto* copy = new CurveProp(*this); + copy->group = nullptr; + return copy; + } + + QVariant getValue() override { return QVariant::fromValue(value); } + + void setValue(QVariant val) override { value = val.value(); } + + QJsonObject toJson() override + { + auto obj = Prop::toJson(); + obj["value"] = value.toJson(); + return obj; + } + + void fromJson(const QJsonObject& obj) override + { + Prop::fromJson(obj); + if (obj.contains("value") && obj["value"].isObject()) + value = Curve::fromJson(obj["value"].toObject()); + else + value = Curve(); // fallback to linear identity + } + + QJsonValue toJsonValue() override { return value.toJson(); } + + void fromJsonValue(const QJsonValue& val) override + { + if (val.isObject()) + value = Curve::fromJson(val.toObject()); + else + value = Curve(); + } +}; + class ImageProp : public Prop { public: @@ -570,14 +616,15 @@ class ImageProp : public Prop { return; auto parts = stringData.split(";base64,"); - if (parts.length() == 0 || parts.length() == 1) + if (parts.length() < 2) return; - auto bytes = QByteArray::fromBase64(parts[0].toUtf8()); + auto bytes = QByteArray::fromBase64(parts[1].toUtf8()); QImage image; - image.loadFromData(QByteArray::fromBase64(stringData.toUtf8())); - this->value = value; + image.loadFromData(bytes); + this->value = image; + _textureDirty = true; } QJsonValue toJsonValue() override @@ -601,13 +648,14 @@ class ImageProp : public Prop { return; auto parts = stringData.split(";base64,"); - if (parts.length() == 0 || parts.length() == 1) + if (parts.length() < 2) return; - auto bytes = QByteArray::fromBase64(parts[0].toUtf8()); + auto bytes = QByteArray::fromBase64(parts[1].toUtf8()); QImage image; - image.loadFromData(QByteArray::fromBase64(stringData.toUtf8())); - this->value = value; + image.loadFromData(bytes); + this->value = image; + _textureDirty = true; } }; \ No newline at end of file diff --git a/src/texturelab/telemetry.cpp b/src/texturelab/telemetry.cpp new file mode 100644 index 00000000..26b91ccd --- /dev/null +++ b/src/texturelab/telemetry.cpp @@ -0,0 +1,74 @@ +#include "telemetry.h" +#include "version.h" + +#include + +#include +#include +#include + +static bool g_enabled = false; + +void Telemetry::init(bool enabled) +{ + // TEXTURELAB_SENTRY_DSN is injected at compile time from CMake. + // Fall back to the hardcoded DSN for local dev builds. + const char* dsn = TEXTURELAB_SENTRY_DSN; + if (!dsn || dsn[0] == '\0') + dsn = "https://93029fba3adb2d1782affd712d013a2b@o216182.ingest.us.sentry.io/4511628329680896"; + if (!enabled) { + g_enabled = false; + return; + } + + sentry_options_t* options = sentry_options_new(); + + sentry_options_set_dsn(options, dsn); + sentry_options_set_release(options, "texturelab@" TEXTURELAB_VERSION "+" TEXTURELAB_BUILD_HASH); + sentry_options_set_debug(options, 0); + + // Writable per-user directory for Crashpad's crash database + QString dataDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + + "/sentry"; + QDir().mkpath(dataDir); + sentry_options_set_database_path(options, dataDir.toStdString().c_str()); + + // crashpad_handler lives next to the texturelab executable + QString handlerPath = QCoreApplication::applicationDirPath() +#ifdef Q_OS_WIN + + "/crashpad_handler.exe"; +#else + + "/crashpad_handler"; +#endif + sentry_options_set_handler_path(options, handlerPath.toStdString().c_str()); + + sentry_init(options); + g_enabled = true; +} + +void Telemetry::close() +{ + if (g_enabled) + sentry_close(); +} + +void Telemetry::breadcrumb(const char* category, const std::string& message) +{ + if (!g_enabled) + return; + + sentry_value_t crumb = sentry_value_new_breadcrumb("default", message.c_str()); + sentry_value_set_by_key(crumb, "category", sentry_value_new_string(category)); + sentry_add_breadcrumb(crumb); +} + +void Telemetry::captureException(const std::string& message) +{ + if (!g_enabled) + return; + + sentry_value_t event = sentry_value_new_event(); + sentry_value_t exc = sentry_value_new_exception("Exception", message.c_str()); + sentry_event_add_exception(event, exc); + sentry_capture_event(event); +} diff --git a/src/texturelab/telemetry.h b/src/texturelab/telemetry.h new file mode 100644 index 00000000..1846150c --- /dev/null +++ b/src/texturelab/telemetry.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +namespace Telemetry { + +// Call before QApplication. No-op if DSN is empty or user opted out. +void init(bool enabled); + +// Call after a.exec() returns to flush queued events. +void close(); + +// Add a breadcrumb (category + message) to the current session context. +void breadcrumb(const char* category, const std::string& message); + +// Capture a handled exception (e.g. from a catch block) as a Sentry error event. +void captureException(const std::string& message); + +} // namespace Telemetry diff --git a/src/texturelab/undo/addcommentcommand.cpp b/src/texturelab/undo/addcommentcommand.cpp new file mode 100644 index 00000000..40fd6794 --- /dev/null +++ b/src/texturelab/undo/addcommentcommand.cpp @@ -0,0 +1,36 @@ +#include "addcommentcommand.h" + +#include "graph/comment.h" +#include "graph/scene.h" + +AddCommentCommand::AddCommentCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + const QString& commentId, + QVector2D pos) + : QUndoCommand("Add Comment") + , _project(project) + , _scene(scene) + , _commentId(commentId) + , _pos(pos) +{} + +void AddCommentCommand::redo() +{ + auto modelComment = CommentPtr(new Comment()); + modelComment->id = _commentId; + modelComment->pos = _pos; + _project->comments[_commentId] = modelComment; + + auto gcomment = nodegraph::Comment::create(); + gcomment->setId(_commentId); + gcomment->setPos(_pos.x(), _pos.y()); + _scene->addComment(gcomment); +} + +void AddCommentCommand::undo() +{ + auto gcomment = _scene->getCommentById(_commentId); + if (gcomment) + _scene->removeComment(gcomment); + _project->comments.remove(_commentId); +} diff --git a/src/texturelab/undo/addcommentcommand.h b/src/texturelab/undo/addcommentcommand.h new file mode 100644 index 00000000..e5496460 --- /dev/null +++ b/src/texturelab/undo/addcommentcommand.h @@ -0,0 +1,27 @@ +#pragma once + +#include "../models.h" +#include +#include + +namespace nodegraph { +class Scene; +typedef QSharedPointer ScenePtr; +} // namespace nodegraph + +class AddCommentCommand : public QUndoCommand { +public: + AddCommentCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + const QString& commentId, + QVector2D pos); + + void redo() override; + void undo() override; + +private: + TextureProjectPtr _project; + nodegraph::ScenePtr _scene; + QString _commentId; + QVector2D _pos; +}; diff --git a/src/texturelab/undo/addconnectioncommand.cpp b/src/texturelab/undo/addconnectioncommand.cpp new file mode 100644 index 00000000..b886af4b --- /dev/null +++ b/src/texturelab/undo/addconnectioncommand.cpp @@ -0,0 +1,65 @@ +#include "addconnectioncommand.h" + +#include "../graphics/texturerenderer.h" +#include "graph/scene.h" + +AddConnectionCommand::AddConnectionCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + const QString& leftNodeId, + const QString& leftOutput, + const QString& rightNodeId, + const QString& rightInput) + : QUndoCommand("Connect Nodes") + , _project(project) + , _scene(scene) + , _renderer(renderer) + , _leftNodeId(leftNodeId) + , _leftOutput(leftOutput) + , _rightNodeId(rightNodeId) + , _rightInput(rightInput) +{} + +void AddConnectionCommand::redo() +{ + if (_firstRedo) { + // Scene already has the connection — just add to project model + auto left = _project->getNodeById(_leftNodeId); + auto right = _project->getNodeById(_rightNodeId); + if (left && right) { + _project->addConnection(left, right, _rightInput); + right->isDirty = true; + } + _firstRedo = false; + } else { + auto leftG = _scene->getNodeById(_leftNodeId); + auto rightG = _scene->getNodeById(_rightNodeId); + if (leftG && rightG) + _scene->connectNodes(leftG, _leftOutput, rightG, _rightInput); + + auto left = _project->getNodeById(_leftNodeId); + auto right = _project->getNodeById(_rightNodeId); + if (left && right) { + _project->addConnection(left, right, _rightInput); + right->isDirty = true; + } + } + if (_renderer) + _renderer->update(); +} + +void AddConnectionCommand::undo() +{ + auto rightG = _scene->getNodeById(_rightNodeId); + if (rightG) { + auto port = rightG->getInPortByName(_rightInput); + if (port && !port->connections.isEmpty()) + _scene->removeConnection(port->connections.first()); + } + auto right = _project->getNodeById(_rightNodeId); + if (right) + right->isDirty = true; + _project->removeConnection(_leftNodeId, _rightNodeId, _rightInput); + if (_renderer) + _renderer->update(); +} diff --git a/src/texturelab/undo/addconnectioncommand.h b/src/texturelab/undo/addconnectioncommand.h new file mode 100644 index 00000000..1535f40a --- /dev/null +++ b/src/texturelab/undo/addconnectioncommand.h @@ -0,0 +1,34 @@ +#pragma once + +#include "../models.h" +#include + +class TextureRenderer; + +namespace nodegraph { +class Scene; +typedef QSharedPointer ScenePtr; +} // namespace nodegraph + +// Records a connection drawn in the scene. First redo is a no-op for the scene +// (already created); subsequent redos recreate it. +class AddConnectionCommand : public QUndoCommand { +public: + AddConnectionCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + const QString& leftNodeId, + const QString& leftOutput, + const QString& rightNodeId, + const QString& rightInput); + + void redo() override; + void undo() override; + +private: + TextureProjectPtr _project; + nodegraph::ScenePtr _scene; + TextureRenderer* _renderer; + QString _leftNodeId, _leftOutput, _rightNodeId, _rightInput; + bool _firstRedo = true; +}; diff --git a/src/texturelab/undo/addframecommand.cpp b/src/texturelab/undo/addframecommand.cpp new file mode 100644 index 00000000..9be7972b --- /dev/null +++ b/src/texturelab/undo/addframecommand.cpp @@ -0,0 +1,36 @@ +#include "addframecommand.h" + +#include "graph/frame.h" +#include "graph/scene.h" + +AddFrameCommand::AddFrameCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + const QString& frameId, + QVector2D pos) + : QUndoCommand("Add Frame") + , _project(project) + , _scene(scene) + , _frameId(frameId) + , _pos(pos) +{} + +void AddFrameCommand::redo() +{ + auto modelFrame = FramePtr(new Frame()); + modelFrame->id = _frameId; + modelFrame->pos = _pos; + _project->frames[_frameId] = modelFrame; + + auto gframe = nodegraph::Frame::create(); + gframe->setId(_frameId); + gframe->setPos(_pos.x(), _pos.y()); + _scene->addFrame(gframe); +} + +void AddFrameCommand::undo() +{ + auto gframe = _scene->getFrameById(_frameId); + if (gframe) + _scene->removeFrame(gframe); + _project->frames.remove(_frameId); +} diff --git a/src/texturelab/undo/addframecommand.h b/src/texturelab/undo/addframecommand.h new file mode 100644 index 00000000..46b0b17f --- /dev/null +++ b/src/texturelab/undo/addframecommand.h @@ -0,0 +1,27 @@ +#pragma once + +#include "../models.h" +#include +#include + +namespace nodegraph { +class Scene; +typedef QSharedPointer ScenePtr; +} // namespace nodegraph + +class AddFrameCommand : public QUndoCommand { +public: + AddFrameCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + const QString& frameId, + QVector2D pos); + + void redo() override; + void undo() override; + +private: + TextureProjectPtr _project; + nodegraph::ScenePtr _scene; + QString _frameId; + QVector2D _pos; +}; diff --git a/src/texturelab/undo/addnodecommand.cpp b/src/texturelab/undo/addnodecommand.cpp new file mode 100644 index 00000000..2f69f8f2 --- /dev/null +++ b/src/texturelab/undo/addnodecommand.cpp @@ -0,0 +1,69 @@ +#include "addnodecommand.h" + +#include "../graphics/texturerenderer.h" +#include "../libraries/library.h" +#include "graph/scene.h" + +#include + +static QString newId() +{ + return QUuid::createUuid().toString(QUuid::WithoutBraces); +} + +static void addNodeToScene(nodegraph::ScenePtr scene, const TextureNodePtr& node) +{ + auto gnode = nodegraph::Node::create(); + gnode->setId(node->id); + gnode->setName(node->title); + for (auto& input : node->inputs) + gnode->addInPort(input); + gnode->addOutPort("output"); + gnode->setCenter(node->pos.x(), node->pos.y()); + scene->addNode(gnode); +} + +AddNodeCommand::AddNodeCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + const QString& typeName, + QVector2D pos) + : QUndoCommand(QString("Add %1 Node").arg(typeName)) + , _project(project) + , _scene(scene) + , _renderer(renderer) + , _typeName(typeName) + , _pos(pos) + , _nodeId(newId()) +{} + +void AddNodeCommand::redo() +{ + auto node = _project->library->createNode(_typeName); + node->id = _nodeId; + node->pos = _pos; + _project->addNode(node); + addNodeToScene(_scene, node); + if (_renderer) + _renderer->update(); +} + +void AddNodeCommand::undo() +{ + auto sceneNode = _scene->getNodeById(_nodeId); + if (sceneNode) + _scene->removeNode(sceneNode); + + for (auto key : _project->connections.keys()) { + auto con = _project->connections.value(key); + if (con->leftNode->id == _nodeId || con->rightNode->id == _nodeId) { + if (con->leftNode->id == _nodeId) + con->rightNode->isDirty = true; + _project->connections.remove(key); + } + } + + _project->nodes.remove(_nodeId); + if (_renderer) + _renderer->update(); +} diff --git a/src/texturelab/undo/addnodecommand.h b/src/texturelab/undo/addnodecommand.h new file mode 100644 index 00000000..2240b683 --- /dev/null +++ b/src/texturelab/undo/addnodecommand.h @@ -0,0 +1,32 @@ +#pragma once + +#include "../models.h" +#include +#include + +class TextureRenderer; + +namespace nodegraph { +class Scene; +typedef QSharedPointer ScenePtr; +} // namespace nodegraph + +class AddNodeCommand : public QUndoCommand { +public: + AddNodeCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + const QString& typeName, + QVector2D pos); + + void redo() override; + void undo() override; + +private: + TextureProjectPtr _project; + nodegraph::ScenePtr _scene; + TextureRenderer* _renderer; + QString _typeName; + QVector2D _pos; + QString _nodeId; // generated in constructor, reused on re-redo +}; diff --git a/src/texturelab/undo/deleteitemscommand.cpp b/src/texturelab/undo/deleteitemscommand.cpp new file mode 100644 index 00000000..8b3109b6 --- /dev/null +++ b/src/texturelab/undo/deleteitemscommand.cpp @@ -0,0 +1,202 @@ +#include "deleteitemscommand.h" + +#include "../graphics/texturerenderer.h" +#include "../libraries/library.h" +#include "../props.h" +#include "graph/comment.h" +#include "graph/frame.h" +#include "graph/scene.h" + +static void addNodeToScene(nodegraph::ScenePtr scene, const TextureNodePtr& node) +{ + auto gnode = nodegraph::Node::create(); + gnode->setId(node->id); + gnode->setName(node->title); + for (auto& input : node->inputs) + gnode->addInPort(input); + gnode->addOutPort("output"); + gnode->setCenter(node->pos.x(), node->pos.y()); + scene->addNode(gnode); +} + +DeleteItemsCommand::DeleteItemsCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + const QList& nodeIds, + const QList& frameIds, + const QList& commentIds) + : QUndoCommand() + , _project(project) + , _scene(scene) + , _renderer(renderer) +{ + int total = nodeIds.size() + frameIds.size() + commentIds.size(); + setText(QString("Delete %1 Item%2").arg(total).arg(total == 1 ? "" : "s")); + + QSet deletedNodeIds(nodeIds.begin(), nodeIds.end()); + + for (const auto& id : nodeIds) { + auto node = _project->getNodeById(id); + if (!node) + continue; + SerializedNode sn; + sn.typeName = node->typeName; + sn.id = node->id; + sn.exportName = node->exportName; + sn.randomSeed = node->randomSeed; + sn.pos = node->pos; + auto gnode = _scene->getNodeById(id); + if (gnode) + sn.pos = QVector2D(gnode->getCenter()); + for (auto key : node->props.keys()) + sn.props[key] = node->props[key]->toJsonValue(); + _nodes.append(sn); + } + + for (const auto& con : _project->connections) { + if (deletedNodeIds.contains(con->leftNode->id) || + deletedNodeIds.contains(con->rightNode->id)) { + SerializedConnection sc; + sc.id = con->id; + sc.leftNodeId = con->leftNode->id; + sc.leftOutput = con->leftNodeOutputName.isEmpty() ? "output" : con->leftNodeOutputName; + sc.rightNodeId = con->rightNode->id; + sc.rightInput = con->rightNodeInputName; + _connections.append(sc); + } + } + + for (const auto& id : frameIds) { + auto frame = _project->frames.value(id); + if (!frame) + continue; + SerializedFrame sf; + sf.id = frame->id; + sf.title = frame->text; + sf.color = frame->color; + sf.pos = frame->pos; + sf.size = frame->size; + auto gframe = _scene->getFrameById(id); + if (gframe) { + sf.pos = QVector2D(gframe->pos()); + auto sz = gframe->frameRect().size(); + sf.size = QVector2D(sz.width(), sz.height()); + } + _frames.append(sf); + } + + for (const auto& id : commentIds) { + auto comment = _project->comments.value(id); + if (!comment) + continue; + SerializedComment sc; + sc.id = comment->id; + sc.text = comment->text; + sc.pos = comment->pos; + auto gcomment = _scene->getCommentById(id); + if (gcomment) + sc.pos = QVector2D(gcomment->pos()); + _comments.append(sc); + } +} + +void DeleteItemsCommand::redo() +{ + for (const auto& sc : _connections) { + auto con = _project->removeConnection(sc.leftNodeId, sc.rightNodeId, sc.rightInput); + if (con && con->rightNode) + con->rightNode->isDirty = true; + } + + for (const auto& sn : _nodes) { + auto gnode = _scene->getNodeById(sn.id); + if (gnode) + _scene->removeNode(gnode); + _project->nodes.remove(sn.id); + } + + for (const auto& sf : _frames) { + auto gframe = _scene->getFrameById(sf.id); + if (gframe) + _scene->removeFrame(gframe); + _project->frames.remove(sf.id); + } + + for (const auto& sc : _comments) { + auto gcomment = _scene->getCommentById(sc.id); + if (gcomment) + _scene->removeComment(gcomment); + _project->comments.remove(sc.id); + } + + if (_renderer) + _renderer->update(); +} + +void DeleteItemsCommand::undo() +{ + for (const auto& sn : _nodes) { + auto node = _project->library->createNode(sn.typeName); + node->id = sn.id; + node->exportName = sn.exportName; + node->randomSeed = sn.randomSeed; + node->pos = sn.pos; + node->isDirty = true; + for (auto key : sn.props.keys()) { + auto prop = node->getProp(key); + if (prop) + prop->fromJsonValue(sn.props[key]); + } + _project->addNode(node); + addNodeToScene(_scene, node); + } + + for (const auto& sc : _connections) { + auto leftNode = _project->getNodeById(sc.leftNodeId); + auto rightNode = _project->getNodeById(sc.rightNodeId); + if (!leftNode || !rightNode) + continue; + _project->addConnection(leftNode, rightNode, sc.rightInput); + rightNode->isDirty = true; + auto leftG = _scene->getNodeById(sc.leftNodeId); + auto rightG = _scene->getNodeById(sc.rightNodeId); + if (leftG && rightG) + _scene->connectNodes(leftG, sc.leftOutput, rightG, sc.rightInput); + } + + for (const auto& sf : _frames) { + auto modelFrame = FramePtr(new Frame()); + modelFrame->id = sf.id; + modelFrame->text = sf.title; + modelFrame->color = sf.color; + modelFrame->pos = sf.pos; + modelFrame->size = sf.size; + _project->frames[sf.id] = modelFrame; + + auto gframe = nodegraph::Frame::create(); + gframe->setId(sf.id); + gframe->setTitle(sf.title); + gframe->setColor(sf.color); + gframe->setPos(sf.pos.x(), sf.pos.y()); + if (sf.size.x() > 0 && sf.size.y() > 0) + gframe->setSize(sf.size.x(), sf.size.y()); + _scene->addFrame(gframe); + } + + for (const auto& sc : _comments) { + auto modelComment = CommentPtr(new Comment()); + modelComment->id = sc.id; + modelComment->text = sc.text; + modelComment->pos = sc.pos; + _project->comments[sc.id] = modelComment; + + auto gcomment = nodegraph::Comment::create(); + gcomment->setId(sc.id); + gcomment->setText(sc.text); + gcomment->setPos(sc.pos.x(), sc.pos.y()); + _scene->addComment(gcomment); + } + + if (_renderer) + _renderer->update(); +} diff --git a/src/texturelab/undo/deleteitemscommand.h b/src/texturelab/undo/deleteitemscommand.h new file mode 100644 index 00000000..f44f82c5 --- /dev/null +++ b/src/texturelab/undo/deleteitemscommand.h @@ -0,0 +1,59 @@ +#pragma once + +#include "../models.h" +#include +#include +#include +#include + +class TextureRenderer; + +namespace nodegraph { +class Scene; +typedef QSharedPointer ScenePtr; +} // namespace nodegraph + +class DeleteItemsCommand : public QUndoCommand { +public: + struct SerializedNode { + QString typeName, id, exportName; + long randomSeed; + QVector2D pos; + QJsonObject props; + }; + + struct SerializedConnection { + QString id, leftNodeId, leftOutput, rightNodeId, rightInput; + }; + + struct SerializedFrame { + QString id, title; + QColor color; + QVector2D pos, size; + }; + + struct SerializedComment { + QString id, text; + QVector2D pos; + }; + + DeleteItemsCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + const QList& nodeIds, + const QList& frameIds, + const QList& commentIds); + + void redo() override; + void undo() override; + +private: + TextureProjectPtr _project; + nodegraph::ScenePtr _scene; + TextureRenderer* _renderer; + + QList _nodes; + QList _connections; + QList _frames; + QList _comments; +}; diff --git a/src/texturelab/undo/editcommentcommand.cpp b/src/texturelab/undo/editcommentcommand.cpp new file mode 100644 index 00000000..b1c426d3 --- /dev/null +++ b/src/texturelab/undo/editcommentcommand.cpp @@ -0,0 +1,44 @@ +#include "editcommentcommand.h" + +#include "graph/comment.h" +#include "graph/scene.h" + +EditCommentCommand::EditCommentCommand(CommentPtr comment, + nodegraph::ScenePtr scene, + const QString& oldText, + const QString& newText) + : QUndoCommand("Edit Comment") + , _comment(comment) + , _scene(scene) + , _commentId(comment->id) + , _oldText(oldText) + , _newText(newText) +{} + +bool EditCommentCommand::mergeWith(const QUndoCommand* other) +{ + auto* cmd = static_cast(other); + if (cmd->_commentId != _commentId) + return false; + _newText = cmd->_newText; + return true; +} + +void EditCommentCommand::apply(const QString& text) +{ + _comment->text = text; + auto gcomment = _scene->getCommentById(_commentId); + if (gcomment) + gcomment->setText(text); +} + +void EditCommentCommand::redo() +{ + if (_firstRedo) { _firstRedo = false; return; } + apply(_newText); +} + +void EditCommentCommand::undo() +{ + apply(_oldText); +} diff --git a/src/texturelab/undo/editcommentcommand.h b/src/texturelab/undo/editcommentcommand.h new file mode 100644 index 00000000..1f499aaf --- /dev/null +++ b/src/texturelab/undo/editcommentcommand.h @@ -0,0 +1,33 @@ +#pragma once + +#include "../models.h" +#include "undocommandids.h" +#include + +namespace nodegraph { +class Scene; +typedef QSharedPointer ScenePtr; +} // namespace nodegraph + +class EditCommentCommand : public QUndoCommand { +public: + EditCommentCommand(CommentPtr comment, + nodegraph::ScenePtr scene, + const QString& oldText, + const QString& newText); + + int id() const override { return UndoCommandId::EditComment; } + bool mergeWith(const QUndoCommand* other) override; + + void redo() override; + void undo() override; + +private: + void apply(const QString& text); + + CommentPtr _comment; + nodegraph::ScenePtr _scene; + QString _commentId; + QString _oldText, _newText; + bool _firstRedo = true; +}; diff --git a/src/texturelab/undo/editframecommand.cpp b/src/texturelab/undo/editframecommand.cpp new file mode 100644 index 00000000..fc9a65fe --- /dev/null +++ b/src/texturelab/undo/editframecommand.cpp @@ -0,0 +1,48 @@ +#include "editframecommand.h" + +#include "graph/frame.h" +#include "graph/scene.h" + +EditFrameCommand::EditFrameCommand(FramePtr frame, + nodegraph::ScenePtr scene, + const QString& oldTitle, const QColor& oldColor, + const QString& newTitle, const QColor& newColor) + : QUndoCommand("Edit Frame") + , _frame(frame) + , _scene(scene) + , _frameId(frame->id) + , _oldTitle(oldTitle), _newTitle(newTitle) + , _oldColor(oldColor), _newColor(newColor) +{} + +bool EditFrameCommand::mergeWith(const QUndoCommand* other) +{ + auto* cmd = static_cast(other); + if (cmd->_frameId != _frameId) + return false; + _newTitle = cmd->_newTitle; + _newColor = cmd->_newColor; + return true; +} + +void EditFrameCommand::apply(const QString& title, const QColor& color) +{ + _frame->text = title; + _frame->color = color; + auto gframe = _scene->getFrameById(_frameId); + if (gframe) { + gframe->setTitle(title); + gframe->setColor(color); + } +} + +void EditFrameCommand::redo() +{ + if (_firstRedo) { _firstRedo = false; return; } + apply(_newTitle, _newColor); +} + +void EditFrameCommand::undo() +{ + apply(_oldTitle, _oldColor); +} diff --git a/src/texturelab/undo/editframecommand.h b/src/texturelab/undo/editframecommand.h new file mode 100644 index 00000000..49d7e3d4 --- /dev/null +++ b/src/texturelab/undo/editframecommand.h @@ -0,0 +1,35 @@ +#pragma once + +#include "../models.h" +#include "undocommandids.h" +#include +#include + +namespace nodegraph { +class Scene; +typedef QSharedPointer ScenePtr; +} // namespace nodegraph + +class EditFrameCommand : public QUndoCommand { +public: + EditFrameCommand(FramePtr frame, + nodegraph::ScenePtr scene, + const QString& oldTitle, const QColor& oldColor, + const QString& newTitle, const QColor& newColor); + + int id() const override { return UndoCommandId::EditFrame; } + bool mergeWith(const QUndoCommand* other) override; + + void redo() override; + void undo() override; + +private: + void apply(const QString& title, const QColor& color); + + FramePtr _frame; + nodegraph::ScenePtr _scene; + QString _frameId; + QString _oldTitle, _newTitle; + QColor _oldColor, _newColor; + bool _firstRedo = true; +}; diff --git a/src/texturelab/undo/moveitemscommand.cpp b/src/texturelab/undo/moveitemscommand.cpp new file mode 100644 index 00000000..5fd2ff2f --- /dev/null +++ b/src/texturelab/undo/moveitemscommand.cpp @@ -0,0 +1,45 @@ +#include "moveitemscommand.h" + +#include "graph/scene.h" + +MoveItemsCommand::MoveItemsCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + const QMap& oldPositions, + const QMap& newPositions) + : QUndoCommand("Move Nodes") + , _project(project) + , _scene(scene) + , _oldPositions(oldPositions) + , _newPositions(newPositions) +{} + +bool MoveItemsCommand::mergeWith(const QUndoCommand* other) +{ + auto* cmd = static_cast(other); + if (cmd->_newPositions.keys() != _newPositions.keys()) + return false; + _newPositions = cmd->_newPositions; + return true; +} + +void MoveItemsCommand::applyPositions(const QMap& positions) +{ + for (auto it = positions.begin(); it != positions.end(); ++it) { + auto gnode = _scene->getNodeById(it.key()); + if (gnode) + gnode->setCenter(it.value().x(), it.value().y()); + auto node = _project->getNodeById(it.key()); + if (node) + node->pos = QVector2D(it.value()); + } +} + +void MoveItemsCommand::redo() +{ + applyPositions(_newPositions); +} + +void MoveItemsCommand::undo() +{ + applyPositions(_oldPositions); +} diff --git a/src/texturelab/undo/moveitemscommand.h b/src/texturelab/undo/moveitemscommand.h new file mode 100644 index 00000000..34943efa --- /dev/null +++ b/src/texturelab/undo/moveitemscommand.h @@ -0,0 +1,34 @@ +#pragma once + +#include "../models.h" +#include "undocommandids.h" +#include +#include +#include + +namespace nodegraph { +class Scene; +typedef QSharedPointer ScenePtr; +} // namespace nodegraph + +class MoveItemsCommand : public QUndoCommand { +public: + MoveItemsCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + const QMap& oldPositions, + const QMap& newPositions); + + int id() const override { return UndoCommandId::MoveItems; } + bool mergeWith(const QUndoCommand* other) override; + + void redo() override; + void undo() override; + +private: + void applyPositions(const QMap& positions); + + TextureProjectPtr _project; + nodegraph::ScenePtr _scene; + QMap _oldPositions; + QMap _newPositions; +}; diff --git a/src/texturelab/undo/pastecommand.cpp b/src/texturelab/undo/pastecommand.cpp new file mode 100644 index 00000000..1a965ae4 --- /dev/null +++ b/src/texturelab/undo/pastecommand.cpp @@ -0,0 +1,119 @@ +#include "pastecommand.h" + +#include "../clipboard.h" +#include "../graphics/texturerenderer.h" +#include "graph/comment.h" +#include "graph/frame.h" +#include "graph/scene.h" + +PasteCommand::PasteCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + QPointF viewCenter) + : QUndoCommand() + , _project(project) + , _scene(scene) + , _renderer(renderer) +{ + if (!Clipboard::pasteItems(project, viewCenter, + _nodes, _connections, _comments, _frames)) + return; + + int total = _nodes.size() + _frames.size() + _comments.size(); + setText(QString("Paste %1 Item%2").arg(total).arg(total == 1 ? "" : "s")); +} + +bool PasteCommand::isEmpty() const +{ + return _nodes.isEmpty() && _frames.isEmpty() && _comments.isEmpty(); +} + +void PasteCommand::addNodeToScene(const TextureNodePtr& node) +{ + auto gnode = nodegraph::Node::create(); + gnode->setId(node->id); + gnode->setName(node->title); + for (auto& input : node->inputs) + gnode->addInPort(input); + gnode->addOutPort("output"); + gnode->setCenter(node->pos.x(), node->pos.y()); + _scene->addNode(gnode); +} + +void PasteCommand::redo() +{ + _scene->clearSelection(); + + for (auto& node : _nodes) { + _project->nodes[node->id] = node; + addNodeToScene(node); + auto gnode = _scene->getNodeById(node->id); + if (gnode) + gnode->setSelected(true); + } + + for (auto& con : _connections) { + _project->connections[con->id] = con; + con->rightNode->isDirty = true; + auto leftG = _scene->getNodeById(con->leftNode->id); + auto rightG = _scene->getNodeById(con->rightNode->id); + if (leftG && rightG) + _scene->connectNodes(leftG, "output", rightG, con->rightNodeInputName); + } + + for (auto& comment : _comments) { + _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 : _frames) { + _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 PasteCommand::undo() +{ + for (auto& con : _connections) + _project->connections.remove(con->id); + + for (auto& node : _nodes) { + auto gnode = _scene->getNodeById(node->id); + if (gnode) + _scene->removeNode(gnode); + _project->nodes.remove(node->id); + } + + for (auto& comment : _comments) { + auto gc = _scene->getCommentById(comment->id); + if (gc) + _scene->removeComment(gc); + _project->comments.remove(comment->id); + } + + for (auto& frame : _frames) { + auto gf = _scene->getFrameById(frame->id); + if (gf) + _scene->removeFrame(gf); + _project->frames.remove(frame->id); + } + + if (_renderer) + _renderer->update(); +} diff --git a/src/texturelab/undo/pastecommand.h b/src/texturelab/undo/pastecommand.h new file mode 100644 index 00000000..a466e523 --- /dev/null +++ b/src/texturelab/undo/pastecommand.h @@ -0,0 +1,37 @@ +#pragma once + +#include "../models.h" +#include +#include + +class TextureRenderer; + +namespace nodegraph { +class Scene; +typedef QSharedPointer ScenePtr; +} // namespace nodegraph + +class PasteCommand : public QUndoCommand { +public: + PasteCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + QPointF viewCenter); + + void redo() override; + void undo() override; + + bool isEmpty() const; + +private: + void addNodeToScene(const TextureNodePtr& node); + + TextureProjectPtr _project; + nodegraph::ScenePtr _scene; + TextureRenderer* _renderer; + + QList _nodes; + QList _connections; + QList _comments; + QList _frames; +}; diff --git a/src/texturelab/undo/propertychangecommand.cpp b/src/texturelab/undo/propertychangecommand.cpp new file mode 100644 index 00000000..cb7be4e3 --- /dev/null +++ b/src/texturelab/undo/propertychangecommand.cpp @@ -0,0 +1,49 @@ +#include "propertychangecommand.h" + +#include "../graphics/texturerenderer.h" + +PropertyChangeCommand::PropertyChangeCommand(TextureNodePtr node, + TextureProjectPtr project, + TextureRenderer* renderer, + const QString& propName, + QVariant oldValue, + QVariant newValue) + : QUndoCommand(QString("Change %1").arg(propName)) + , _node(node) + , _project(project) + , _renderer(renderer) + , _propName(propName) + , _oldValue(oldValue) + , _newValue(newValue) +{} + +bool PropertyChangeCommand::mergeWith(const QUndoCommand* other) +{ + auto* cmd = static_cast(other); + if (cmd->_node != _node || cmd->_propName != _propName) + return false; + _newValue = cmd->_newValue; + return true; +} + +void PropertyChangeCommand::applyValue(const QVariant& value) +{ + _node->setProp(_propName, value); + _project->markNodeAsDirty(_node); + if (_renderer) + _renderer->update(); +} + +void PropertyChangeCommand::redo() +{ + if (_firstRedo) { + _firstRedo = false; + return; // already applied by the signal handler + } + applyValue(_newValue); +} + +void PropertyChangeCommand::undo() +{ + applyValue(_oldValue); +} diff --git a/src/texturelab/undo/propertychangecommand.h b/src/texturelab/undo/propertychangecommand.h new file mode 100644 index 00000000..efc9f6fb --- /dev/null +++ b/src/texturelab/undo/propertychangecommand.h @@ -0,0 +1,38 @@ +#pragma once + +#include "../models.h" +#include "undocommandids.h" +#include +#include + +class TextureRenderer; + +// A single node property value change. Consecutive changes to the same prop +// merge into one undo step. First redo is skipped (already applied by the +// signal handler). +class PropertyChangeCommand : public QUndoCommand { +public: + PropertyChangeCommand(TextureNodePtr node, + TextureProjectPtr project, + TextureRenderer* renderer, + const QString& propName, + QVariant oldValue, + QVariant newValue); + + int id() const override { return UndoCommandId::PropertyChange; } + bool mergeWith(const QUndoCommand* other) override; + + void redo() override; + void undo() override; + +private: + void applyValue(const QVariant& value); + + TextureNodePtr _node; + TextureProjectPtr _project; + TextureRenderer* _renderer; + QString _propName; + QVariant _oldValue; + QVariant _newValue; + bool _firstRedo = true; +}; diff --git a/src/texturelab/undo/randomseedchangecommand.cpp b/src/texturelab/undo/randomseedchangecommand.cpp new file mode 100644 index 00000000..a9cb8601 --- /dev/null +++ b/src/texturelab/undo/randomseedchangecommand.cpp @@ -0,0 +1,36 @@ +#include "randomseedchangecommand.h" + +#include "../graphics/texturerenderer.h" + +RandomSeedChangeCommand::RandomSeedChangeCommand(TextureNodePtr node, + TextureProjectPtr project, + TextureRenderer* renderer, + long oldSeed, + long newSeed) + : QUndoCommand("Change Random Seed") + , _node(node) + , _project(project) + , _renderer(renderer) + , _oldSeed(oldSeed) + , _newSeed(newSeed) +{} + +void RandomSeedChangeCommand::redo() +{ + if (_firstRedo) { + _firstRedo = false; + return; + } + _node->randomSeed = _newSeed; + _project->markNodeAsDirty(_node); + if (_renderer) + _renderer->update(); +} + +void RandomSeedChangeCommand::undo() +{ + _node->randomSeed = _oldSeed; + _project->markNodeAsDirty(_node); + if (_renderer) + _renderer->update(); +} diff --git a/src/texturelab/undo/randomseedchangecommand.h b/src/texturelab/undo/randomseedchangecommand.h new file mode 100644 index 00000000..3eafd46e --- /dev/null +++ b/src/texturelab/undo/randomseedchangecommand.h @@ -0,0 +1,25 @@ +#pragma once + +#include "../models.h" +#include + +class TextureRenderer; + +class RandomSeedChangeCommand : public QUndoCommand { +public: + RandomSeedChangeCommand(TextureNodePtr node, + TextureProjectPtr project, + TextureRenderer* renderer, + long oldSeed, + long newSeed); + + void redo() override; + void undo() override; + +private: + TextureNodePtr _node; + TextureProjectPtr _project; + TextureRenderer* _renderer; + long _oldSeed, _newSeed; + bool _firstRedo = true; +}; diff --git a/src/texturelab/undo/removeconnectioncommand.cpp b/src/texturelab/undo/removeconnectioncommand.cpp new file mode 100644 index 00000000..bdbeccf6 --- /dev/null +++ b/src/texturelab/undo/removeconnectioncommand.cpp @@ -0,0 +1,61 @@ +#include "removeconnectioncommand.h" + +#include "../graphics/texturerenderer.h" +#include "graph/scene.h" + +RemoveConnectionCommand::RemoveConnectionCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + const QString& leftNodeId, + const QString& leftOutput, + const QString& rightNodeId, + const QString& rightInput) + : QUndoCommand("Remove Connection") + , _project(project) + , _scene(scene) + , _renderer(renderer) + , _leftNodeId(leftNodeId) + , _leftOutput(leftOutput) + , _rightNodeId(rightNodeId) + , _rightInput(rightInput) +{} + +void RemoveConnectionCommand::redo() +{ + if (_firstRedo) { + // Scene already removed it — just remove from project model + auto con = _project->removeConnection(_leftNodeId, _rightNodeId, _rightInput); + if (con && con->rightNode) + con->rightNode->isDirty = true; + _firstRedo = false; + } else { + auto rightG = _scene->getNodeById(_rightNodeId); + if (rightG) { + auto port = rightG->getInPortByName(_rightInput); + if (port && !port->connections.isEmpty()) + _scene->removeConnection(port->connections.first()); + } + auto con = _project->removeConnection(_leftNodeId, _rightNodeId, _rightInput); + if (con && con->rightNode) + con->rightNode->isDirty = true; + } + if (_renderer) + _renderer->update(); +} + +void RemoveConnectionCommand::undo() +{ + auto leftG = _scene->getNodeById(_leftNodeId); + auto rightG = _scene->getNodeById(_rightNodeId); + if (leftG && rightG) + _scene->connectNodes(leftG, _leftOutput, rightG, _rightInput); + + auto left = _project->getNodeById(_leftNodeId); + auto right = _project->getNodeById(_rightNodeId); + if (left && right) { + _project->addConnection(left, right, _rightInput); + right->isDirty = true; + } + if (_renderer) + _renderer->update(); +} diff --git a/src/texturelab/undo/removeconnectioncommand.h b/src/texturelab/undo/removeconnectioncommand.h new file mode 100644 index 00000000..9fc1eb7a --- /dev/null +++ b/src/texturelab/undo/removeconnectioncommand.h @@ -0,0 +1,34 @@ +#pragma once + +#include "../models.h" +#include + +class TextureRenderer; + +namespace nodegraph { +class Scene; +typedef QSharedPointer ScenePtr; +} // namespace nodegraph + +// Records a connection removed in the scene. First redo only removes from the +// project model (scene already done). +class RemoveConnectionCommand : public QUndoCommand { +public: + RemoveConnectionCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + const QString& leftNodeId, + const QString& leftOutput, + const QString& rightNodeId, + const QString& rightInput); + + void redo() override; + void undo() override; + +private: + TextureProjectPtr _project; + nodegraph::ScenePtr _scene; + TextureRenderer* _renderer; + QString _leftNodeId, _leftOutput, _rightNodeId, _rightInput; + bool _firstRedo = true; +}; diff --git a/src/texturelab/undo/texturechannelassigncommand.cpp b/src/texturelab/undo/texturechannelassigncommand.cpp new file mode 100644 index 00000000..8fec1a4d --- /dev/null +++ b/src/texturelab/undo/texturechannelassigncommand.cpp @@ -0,0 +1,36 @@ +#include "texturechannelassigncommand.h" + +TextureChannelAssignCommand::TextureChannelAssignCommand( + TextureProjectPtr project, + TextureChannel channel, + const QString& oldNodeId, + const QString& newNodeId, + std::function syncViewer) + : QUndoCommand("Assign Texture Channel") + , _project(project) + , _channel(channel) + , _oldNodeId(oldNodeId) + , _newNodeId(newNodeId) + , _syncViewer(syncViewer) +{} + +void TextureChannelAssignCommand::apply(const QString& nodeId) +{ + if (nodeId.isEmpty()) + _project->textureChannels.remove(_channel); + else + _project->textureChannels[_channel] = nodeId; + if (_syncViewer) + _syncViewer(); +} + +void TextureChannelAssignCommand::redo() +{ + if (_firstRedo) { _firstRedo = false; return; } + apply(_newNodeId); +} + +void TextureChannelAssignCommand::undo() +{ + apply(_oldNodeId); +} diff --git a/src/texturelab/undo/texturechannelassigncommand.h b/src/texturelab/undo/texturechannelassigncommand.h new file mode 100644 index 00000000..c91776a0 --- /dev/null +++ b/src/texturelab/undo/texturechannelassigncommand.h @@ -0,0 +1,26 @@ +#pragma once + +#include "../models.h" +#include +#include + +class TextureChannelAssignCommand : public QUndoCommand { +public: + TextureChannelAssignCommand(TextureProjectPtr project, + TextureChannel channel, + const QString& oldNodeId, + const QString& newNodeId, + std::function syncViewer); + + void redo() override; + void undo() override; + +private: + void apply(const QString& nodeId); + + TextureProjectPtr _project; + TextureChannel _channel; + QString _oldNodeId, _newNodeId; + std::function _syncViewer; + bool _firstRedo = true; +}; diff --git a/src/texturelab/undo/undocommandids.h b/src/texturelab/undo/undocommandids.h new file mode 100644 index 00000000..0d80bce0 --- /dev/null +++ b/src/texturelab/undo/undocommandids.h @@ -0,0 +1,8 @@ +#pragma once + +namespace UndoCommandId { +static constexpr int PropertyChange = 1; +static constexpr int MoveItems = 2; +static constexpr int EditFrame = 3; +static constexpr int EditComment = 4; +} // namespace UndoCommandId diff --git a/src/texturelab/undo/undocommands.cpp b/src/texturelab/undo/undocommands.cpp new file mode 100644 index 00000000..07d9a5fb --- /dev/null +++ b/src/texturelab/undo/undocommands.cpp @@ -0,0 +1,871 @@ +#include "undocommands.h" + +#include "../clipboard.h" +#include "../graphics/texturerenderer.h" +#include "../libraries/library.h" +#include "../props.h" +#include "graph/comment.h" +#include "graph/frame.h" +#include "graph/scene.h" + +#include + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static QString newId() +{ + return QUuid::createUuid().toString(QUuid::WithoutBraces); +} + +// Add a TextureNode to the nodegraph scene (mirrors GraphWidget::addNode) +static void addNodeToScene(nodegraph::ScenePtr scene, const TextureNodePtr& node) +{ + auto gnode = nodegraph::Node::create(); + gnode->setId(node->id); + gnode->setName(node->title); + for (auto& input : node->inputs) + gnode->addInPort(input); + gnode->addOutPort("output"); + gnode->setCenter(node->pos.x(), node->pos.y()); + scene->addNode(gnode); +} + +// --------------------------------------------------------------------------- +// AddNodeCommand +// --------------------------------------------------------------------------- + +AddNodeCommand::AddNodeCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + const QString& typeName, + QVector2D pos) + : QUndoCommand(QString("Add %1 Node").arg(typeName)) + , _project(project) + , _scene(scene) + , _renderer(renderer) + , _typeName(typeName) + , _pos(pos) + , _nodeId(newId()) +{} + +void AddNodeCommand::redo() +{ + auto node = _project->library->createNode(_typeName); + node->id = _nodeId; + node->pos = _pos; + _project->addNode(node); + addNodeToScene(_scene, node); + if (_renderer) + _renderer->update(); +} + +void AddNodeCommand::undo() +{ + auto sceneNode = _scene->getNodeById(_nodeId); + if (sceneNode) + _scene->removeNode(sceneNode); + + // Remove all connections involving this node from the project + for (auto key : _project->connections.keys()) { + auto con = _project->connections.value(key); + if (con->leftNode->id == _nodeId || con->rightNode->id == _nodeId) { + if (con->leftNode->id == _nodeId) + con->rightNode->isDirty = true; + _project->connections.remove(key); + } + } + + _project->nodes.remove(_nodeId); + if (_renderer) + _renderer->update(); +} + +// --------------------------------------------------------------------------- +// DeleteItemsCommand +// --------------------------------------------------------------------------- + +DeleteItemsCommand::DeleteItemsCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + const QList& nodeIds, + const QList& frameIds, + const QList& commentIds) + : QUndoCommand() + , _project(project) + , _scene(scene) + , _renderer(renderer) +{ + // Determine display text + int total = nodeIds.size() + frameIds.size() + commentIds.size(); + setText(QString("Delete %1 Item%2").arg(total).arg(total == 1 ? "" : "s")); + + QSet deletedNodeIds(nodeIds.begin(), nodeIds.end()); + + // Serialize nodes + for (const auto& id : nodeIds) { + auto node = _project->getNodeById(id); + if (!node) + continue; + SerializedNode sn; + sn.typeName = node->typeName; + sn.id = node->id; + sn.exportName = node->exportName; + sn.randomSeed = node->randomSeed; + sn.pos = node->pos; + // Sync visual position before serializing + auto gnode = _scene->getNodeById(id); + if (gnode) + sn.pos = QVector2D(gnode->getCenter()); + for (auto key : node->props.keys()) + sn.props[key] = node->props[key]->toJsonValue(); + _nodes.append(sn); + } + + // Serialize all connections touching any deleted node + for (const auto& con : _project->connections) { + if (deletedNodeIds.contains(con->leftNode->id) || + deletedNodeIds.contains(con->rightNode->id)) { + SerializedConnection sc; + sc.id = con->id; + sc.leftNodeId = con->leftNode->id; + sc.leftOutput = con->leftNodeOutputName.isEmpty() ? "output" : con->leftNodeOutputName; + sc.rightNodeId = con->rightNode->id; + sc.rightInput = con->rightNodeInputName; + _connections.append(sc); + } + } + + // Serialize frames + for (const auto& id : frameIds) { + auto frame = _project->frames.value(id); + if (!frame) + continue; + SerializedFrame sf; + sf.id = frame->id; + sf.title = frame->text; + sf.color = frame->color; + sf.pos = frame->pos; + sf.size = frame->size; + // Sync visual position/size + auto gframe = _scene->getFrameById(id); + if (gframe) { + sf.pos = QVector2D(gframe->pos()); + auto sz = gframe->frameRect().size(); + sf.size = QVector2D(sz.width(), sz.height()); + } + _frames.append(sf); + } + + // Serialize comments + for (const auto& id : commentIds) { + auto comment = _project->comments.value(id); + if (!comment) + continue; + SerializedComment sc; + sc.id = comment->id; + sc.text = comment->text; + sc.pos = comment->pos; + auto gcomment = _scene->getCommentById(id); + if (gcomment) + sc.pos = QVector2D(gcomment->pos()); + _comments.append(sc); + } +} + +void DeleteItemsCommand::redo() +{ + // Remove project connections first (mark downstream nodes dirty) + for (const auto& sc : _connections) { + auto con = _project->removeConnection(sc.leftNodeId, sc.rightNodeId, sc.rightInput); + if (con && con->rightNode) + con->rightNode->isDirty = true; + } + + // Remove nodes (scene::removeNode also removes scene-level connections) + for (const auto& sn : _nodes) { + auto gnode = _scene->getNodeById(sn.id); + if (gnode) + _scene->removeNode(gnode); + _project->nodes.remove(sn.id); + } + + for (const auto& sf : _frames) { + auto gframe = _scene->getFrameById(sf.id); + if (gframe) + _scene->removeFrame(gframe); + _project->frames.remove(sf.id); + } + + for (const auto& sc : _comments) { + auto gcomment = _scene->getCommentById(sc.id); + if (gcomment) + _scene->removeComment(gcomment); + _project->comments.remove(sc.id); + } + + if (_renderer) + _renderer->update(); +} + +void DeleteItemsCommand::undo() +{ + // Recreate nodes in project + scene + for (const auto& sn : _nodes) { + auto node = _project->library->createNode(sn.typeName); + node->id = sn.id; + node->exportName = sn.exportName; + node->randomSeed = sn.randomSeed; + node->pos = sn.pos; + node->isDirty = true; + + for (auto key : sn.props.keys()) { + auto prop = node->getProp(key); + if (prop) + prop->fromJsonValue(sn.props[key]); + } + + _project->addNode(node); + addNodeToScene(_scene, node); + } + + // Recreate connections in project + scene + for (const auto& sc : _connections) { + auto leftNode = _project->getNodeById(sc.leftNodeId); + auto rightNode = _project->getNodeById(sc.rightNodeId); + if (!leftNode || !rightNode) + continue; + + _project->addConnection(leftNode, rightNode, sc.rightInput); + rightNode->isDirty = true; + + auto leftGNode = _scene->getNodeById(sc.leftNodeId); + auto rightGNode = _scene->getNodeById(sc.rightNodeId); + if (leftGNode && rightGNode) + _scene->connectNodes(leftGNode, sc.leftOutput, rightGNode, sc.rightInput); + } + + // Recreate frames + for (const auto& sf : _frames) { + auto modelFrame = FramePtr(new Frame()); + modelFrame->id = sf.id; + modelFrame->text = sf.title; + modelFrame->color = sf.color; + modelFrame->pos = sf.pos; + modelFrame->size = sf.size; + _project->frames[sf.id] = modelFrame; + + auto gframe = nodegraph::Frame::create(); + gframe->setId(sf.id); + gframe->setTitle(sf.title); + gframe->setColor(sf.color); + gframe->setPos(sf.pos.x(), sf.pos.y()); + if (sf.size.x() > 0 && sf.size.y() > 0) + gframe->setSize(sf.size.x(), sf.size.y()); + _scene->addFrame(gframe); + } + + // Recreate comments + for (const auto& sc : _comments) { + auto modelComment = CommentPtr(new Comment()); + modelComment->id = sc.id; + modelComment->text = sc.text; + modelComment->pos = sc.pos; + _project->comments[sc.id] = modelComment; + + auto gcomment = nodegraph::Comment::create(); + gcomment->setId(sc.id); + gcomment->setText(sc.text); + gcomment->setPos(sc.pos.x(), sc.pos.y()); + _scene->addComment(gcomment); + } + + if (_renderer) + _renderer->update(); +} + +// --------------------------------------------------------------------------- +// AddConnectionCommand +// --------------------------------------------------------------------------- + +AddConnectionCommand::AddConnectionCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + const QString& leftNodeId, + const QString& leftOutput, + const QString& rightNodeId, + const QString& rightInput) + : QUndoCommand(QString("Connect Nodes")) + , _project(project) + , _scene(scene) + , _renderer(renderer) + , _leftNodeId(leftNodeId) + , _leftOutput(leftOutput) + , _rightNodeId(rightNodeId) + , _rightInput(rightInput) +{} + +void AddConnectionCommand::redo() +{ + if (_firstRedo) { + // Scene already has the connection — just add to project model + auto left = _project->getNodeById(_leftNodeId); + auto right = _project->getNodeById(_rightNodeId); + if (left && right) { + _project->addConnection(left, right, _rightInput); + right->isDirty = true; + } + _firstRedo = false; + } else { + // Recreate in scene + project + auto leftG = _scene->getNodeById(_leftNodeId); + auto rightG = _scene->getNodeById(_rightNodeId); + if (leftG && rightG) + _scene->connectNodes(leftG, _leftOutput, rightG, _rightInput); + + auto left = _project->getNodeById(_leftNodeId); + auto right = _project->getNodeById(_rightNodeId); + if (left && right) { + _project->addConnection(left, right, _rightInput); + right->isDirty = true; + } + } + if (_renderer) + _renderer->update(); +} + +void AddConnectionCommand::undo() +{ + // Remove from scene + auto rightG = _scene->getNodeById(_rightNodeId); + if (rightG) { + auto port = rightG->getInPortByName(_rightInput); + if (port && !port->connections.isEmpty()) + _scene->removeConnection(port->connections.first()); + } + // Remove from project + auto right = _project->getNodeById(_rightNodeId); + if (right) + right->isDirty = true; + _project->removeConnection(_leftNodeId, _rightNodeId, _rightInput); + if (_renderer) + _renderer->update(); +} + +// --------------------------------------------------------------------------- +// RemoveConnectionCommand +// --------------------------------------------------------------------------- + +RemoveConnectionCommand::RemoveConnectionCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + const QString& leftNodeId, + const QString& leftOutput, + const QString& rightNodeId, + const QString& rightInput) + : QUndoCommand("Remove Connection") + , _project(project) + , _scene(scene) + , _renderer(renderer) + , _leftNodeId(leftNodeId) + , _leftOutput(leftOutput) + , _rightNodeId(rightNodeId) + , _rightInput(rightInput) +{} + +void RemoveConnectionCommand::redo() +{ + if (_firstRedo) { + // Scene already removed it — just remove from project model + auto con = _project->removeConnection(_leftNodeId, _rightNodeId, _rightInput); + if (con && con->rightNode) + con->rightNode->isDirty = true; + _firstRedo = false; + } else { + // Remove from scene + auto rightG = _scene->getNodeById(_rightNodeId); + if (rightG) { + auto port = rightG->getInPortByName(_rightInput); + if (port && !port->connections.isEmpty()) + _scene->removeConnection(port->connections.first()); + } + // Remove from project + auto con = _project->removeConnection(_leftNodeId, _rightNodeId, _rightInput); + if (con && con->rightNode) + con->rightNode->isDirty = true; + } + if (_renderer) + _renderer->update(); +} + +void RemoveConnectionCommand::undo() +{ + // Restore in scene + auto leftG = _scene->getNodeById(_leftNodeId); + auto rightG = _scene->getNodeById(_rightNodeId); + if (leftG && rightG) + _scene->connectNodes(leftG, _leftOutput, rightG, _rightInput); + + // Restore in project + auto left = _project->getNodeById(_leftNodeId); + auto right = _project->getNodeById(_rightNodeId); + if (left && right) { + _project->addConnection(left, right, _rightInput); + right->isDirty = true; + } + if (_renderer) + _renderer->update(); +} + +// --------------------------------------------------------------------------- +// MoveItemsCommand +// --------------------------------------------------------------------------- + +MoveItemsCommand::MoveItemsCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + const QMap& oldPositions, + const QMap& newPositions) + : QUndoCommand("Move Nodes") + , _project(project) + , _scene(scene) + , _oldPositions(oldPositions) + , _newPositions(newPositions) +{} + +bool MoveItemsCommand::mergeWith(const QUndoCommand* other) +{ + auto* cmd = static_cast(other); + if (cmd->_newPositions.keys() != _newPositions.keys()) + return false; + _newPositions = cmd->_newPositions; + return true; +} + +void MoveItemsCommand::applyPositions(const QMap& positions) +{ + for (auto it = positions.begin(); it != positions.end(); ++it) { + auto gnode = _scene->getNodeById(it.key()); + if (gnode) + gnode->setCenter(it.value().x(), it.value().y()); + auto node = _project->getNodeById(it.key()); + if (node) + node->pos = QVector2D(it.value()); + } +} + +void MoveItemsCommand::redo() +{ + applyPositions(_newPositions); +} + +void MoveItemsCommand::undo() +{ + applyPositions(_oldPositions); +} + +// --------------------------------------------------------------------------- +// PropertyChangeCommand +// --------------------------------------------------------------------------- + +PropertyChangeCommand::PropertyChangeCommand(TextureNodePtr node, + TextureProjectPtr project, + TextureRenderer* renderer, + const QString& propName, + QVariant oldValue, + QVariant newValue) + : QUndoCommand(QString("Change %1").arg(propName)) + , _node(node) + , _project(project) + , _renderer(renderer) + , _propName(propName) + , _oldValue(oldValue) + , _newValue(newValue) +{} + +bool PropertyChangeCommand::mergeWith(const QUndoCommand* other) +{ + auto* cmd = static_cast(other); + if (cmd->_node != _node || cmd->_propName != _propName) + return false; + _newValue = cmd->_newValue; + return true; +} + +void PropertyChangeCommand::applyValue(const QVariant& value) +{ + _node->setProp(_propName, value); + _project->markNodeAsDirty(_node); + if (_renderer) + _renderer->update(); +} + +void PropertyChangeCommand::redo() +{ + if (_firstRedo) { + _firstRedo = false; + return; // already applied by the signal handler + } + applyValue(_newValue); +} + +void PropertyChangeCommand::undo() +{ + applyValue(_oldValue); +} + +// --------------------------------------------------------------------------- +// RandomSeedChangeCommand +// --------------------------------------------------------------------------- + +RandomSeedChangeCommand::RandomSeedChangeCommand(TextureNodePtr node, + TextureProjectPtr project, + TextureRenderer* renderer, + long oldSeed, + long newSeed) + : QUndoCommand("Change Random Seed") + , _node(node) + , _project(project) + , _renderer(renderer) + , _oldSeed(oldSeed) + , _newSeed(newSeed) +{} + +void RandomSeedChangeCommand::redo() +{ + if (_firstRedo) { + _firstRedo = false; + return; + } + _node->randomSeed = _newSeed; + _project->markNodeAsDirty(_node); + if (_renderer) + _renderer->update(); +} + +void RandomSeedChangeCommand::undo() +{ + _node->randomSeed = _oldSeed; + _project->markNodeAsDirty(_node); + if (_renderer) + _renderer->update(); +} + +// --------------------------------------------------------------------------- +// AddFrameCommand +// --------------------------------------------------------------------------- + +AddFrameCommand::AddFrameCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + const QString& frameId, + QVector2D pos) + : QUndoCommand("Add Frame") + , _project(project) + , _scene(scene) + , _frameId(frameId) + , _pos(pos) +{} + +void AddFrameCommand::redo() +{ + auto modelFrame = FramePtr(new Frame()); + modelFrame->id = _frameId; + modelFrame->pos = _pos; + _project->frames[_frameId] = modelFrame; + + auto gframe = nodegraph::Frame::create(); + gframe->setId(_frameId); + gframe->setPos(_pos.x(), _pos.y()); + _scene->addFrame(gframe); +} + +void AddFrameCommand::undo() +{ + auto gframe = _scene->getFrameById(_frameId); + if (gframe) + _scene->removeFrame(gframe); + _project->frames.remove(_frameId); +} + +// --------------------------------------------------------------------------- +// AddCommentCommand +// --------------------------------------------------------------------------- + +AddCommentCommand::AddCommentCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + const QString& commentId, + QVector2D pos) + : QUndoCommand("Add Comment") + , _project(project) + , _scene(scene) + , _commentId(commentId) + , _pos(pos) +{} + +void AddCommentCommand::redo() +{ + auto modelComment = CommentPtr(new Comment()); + modelComment->id = _commentId; + modelComment->pos = _pos; + _project->comments[_commentId] = modelComment; + + auto gcomment = nodegraph::Comment::create(); + gcomment->setId(_commentId); + gcomment->setPos(_pos.x(), _pos.y()); + _scene->addComment(gcomment); +} + +void AddCommentCommand::undo() +{ + auto gcomment = _scene->getCommentById(_commentId); + if (gcomment) + _scene->removeComment(gcomment); + _project->comments.remove(_commentId); +} + +// --------------------------------------------------------------------------- +// EditFrameCommand +// --------------------------------------------------------------------------- + +EditFrameCommand::EditFrameCommand(FramePtr frame, + nodegraph::ScenePtr scene, + const QString& oldTitle, const QColor& oldColor, + const QString& newTitle, const QColor& newColor) + : QUndoCommand("Edit Frame") + , _frame(frame) + , _scene(scene) + , _frameId(frame->id) + , _oldTitle(oldTitle), _newTitle(newTitle) + , _oldColor(oldColor), _newColor(newColor) +{} + +bool EditFrameCommand::mergeWith(const QUndoCommand* other) +{ + auto* cmd = static_cast(other); + if (cmd->_frameId != _frameId) + return false; + _newTitle = cmd->_newTitle; + _newColor = cmd->_newColor; + return true; +} + +void EditFrameCommand::apply(const QString& title, const QColor& color) +{ + _frame->text = title; + _frame->color = color; + auto gframe = _scene->getFrameById(_frameId); + if (gframe) { + gframe->setTitle(title); + gframe->setColor(color); + } +} + +void EditFrameCommand::redo() +{ + if (_firstRedo) { _firstRedo = false; return; } + apply(_newTitle, _newColor); +} + +void EditFrameCommand::undo() +{ + apply(_oldTitle, _oldColor); +} + +// --------------------------------------------------------------------------- +// EditCommentCommand +// --------------------------------------------------------------------------- + +EditCommentCommand::EditCommentCommand(CommentPtr comment, + nodegraph::ScenePtr scene, + const QString& oldText, + const QString& newText) + : QUndoCommand("Edit Comment") + , _comment(comment) + , _scene(scene) + , _commentId(comment->id) + , _oldText(oldText) + , _newText(newText) +{} + +bool EditCommentCommand::mergeWith(const QUndoCommand* other) +{ + auto* cmd = static_cast(other); + if (cmd->_commentId != _commentId) + return false; + _newText = cmd->_newText; + return true; +} + +void EditCommentCommand::apply(const QString& text) +{ + _comment->text = text; + auto gcomment = _scene->getCommentById(_commentId); + if (gcomment) + gcomment->setText(text); +} + +void EditCommentCommand::redo() +{ + if (_firstRedo) { _firstRedo = false; return; } + apply(_newText); +} + +void EditCommentCommand::undo() +{ + apply(_oldText); +} + +// --------------------------------------------------------------------------- +// PasteCommand +// --------------------------------------------------------------------------- + +PasteCommand::PasteCommand(TextureProjectPtr project, + nodegraph::ScenePtr scene, + TextureRenderer* renderer, + QPointF viewCenter) + : QUndoCommand() + , _project(project) + , _scene(scene) + , _renderer(renderer) +{ + if (!Clipboard::pasteItems(project, viewCenter, + _nodes, _connections, _comments, _frames)) + return; // nothing to paste + + int total = _nodes.size() + _frames.size() + _comments.size(); + setText(QString("Paste %1 Item%2").arg(total).arg(total == 1 ? "" : "s")); +} + +bool PasteCommand::isEmpty() const +{ + return _nodes.isEmpty() && _frames.isEmpty() && _comments.isEmpty(); +} + +void PasteCommand::addNodeToScene(const TextureNodePtr& node) +{ + auto gnode = nodegraph::Node::create(); + gnode->setId(node->id); + gnode->setName(node->title); + for (auto& input : node->inputs) + gnode->addInPort(input); + gnode->addOutPort("output"); + gnode->setCenter(node->pos.x(), node->pos.y()); + _scene->addNode(gnode); +} + +void PasteCommand::redo() +{ + _scene->clearSelection(); + + for (auto& node : _nodes) { + _project->nodes[node->id] = node; + addNodeToScene(node); + auto gnode = _scene->getNodeById(node->id); + if (gnode) + gnode->setSelected(true); + } + + for (auto& con : _connections) { + _project->connections[con->id] = con; + con->rightNode->isDirty = true; + auto leftG = _scene->getNodeById(con->leftNode->id); + auto rightG = _scene->getNodeById(con->rightNode->id); + if (leftG && rightG) + _scene->connectNodes(leftG, "output", rightG, con->rightNodeInputName); + } + + for (auto& comment : _comments) { + _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 : _frames) { + _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 PasteCommand::undo() +{ + for (auto& con : _connections) + _project->connections.remove(con->id); + + for (auto& node : _nodes) { + auto gnode = _scene->getNodeById(node->id); + if (gnode) + _scene->removeNode(gnode); + _project->nodes.remove(node->id); + } + + for (auto& comment : _comments) { + auto gc = _scene->getCommentById(comment->id); + if (gc) + _scene->removeComment(gc); + _project->comments.remove(comment->id); + } + + for (auto& frame : _frames) { + auto gf = _scene->getFrameById(frame->id); + if (gf) + _scene->removeFrame(gf); + _project->frames.remove(frame->id); + } + + if (_renderer) + _renderer->update(); +} + +// --------------------------------------------------------------------------- +// TextureChannelAssignCommand +// --------------------------------------------------------------------------- + +TextureChannelAssignCommand::TextureChannelAssignCommand( + TextureProjectPtr project, + TextureChannel channel, + const QString& oldNodeId, + const QString& newNodeId, + std::function syncViewer) + : QUndoCommand("Assign Texture Channel") + , _project(project) + , _channel(channel) + , _oldNodeId(oldNodeId) + , _newNodeId(newNodeId) + , _syncViewer(syncViewer) +{} + +void TextureChannelAssignCommand::apply(const QString& nodeId) +{ + if (nodeId.isEmpty()) + _project->textureChannels.remove(_channel); + else + _project->textureChannels[_channel] = nodeId; + if (_syncViewer) + _syncViewer(); +} + +void TextureChannelAssignCommand::redo() +{ + if (_firstRedo) { _firstRedo = false; return; } + apply(_newNodeId); +} + +void TextureChannelAssignCommand::undo() +{ + apply(_oldNodeId); +} diff --git a/src/texturelab/undo/undocommands.h b/src/texturelab/undo/undocommands.h new file mode 100644 index 00000000..e2a950bc --- /dev/null +++ b/src/texturelab/undo/undocommands.h @@ -0,0 +1,16 @@ +#pragma once + +#include "addcommentcommand.h" +#include "addconnectioncommand.h" +#include "addframecommand.h" +#include "addnodecommand.h" +#include "deleteitemscommand.h" +#include "editcommentcommand.h" +#include "editframecommand.h" +#include "moveitemscommand.h" +#include "pastecommand.h" +#include "propertychangecommand.h" +#include "randomseedchangecommand.h" +#include "removeconnectioncommand.h" +#include "texturechannelassigncommand.h" +#include "undocommandids.h" diff --git a/src/texturelab/version.h.in b/src/texturelab/version.h.in new file mode 100644 index 00000000..242faa38 --- /dev/null +++ b/src/texturelab/version.h.in @@ -0,0 +1,2 @@ +#pragma once +#define TEXTURELAB_BUILD_HASH "@TEXTURELAB_BUILD_HASH@" diff --git a/src/texturelab/widgets/aboutdialog.cpp b/src/texturelab/widgets/aboutdialog.cpp new file mode 100644 index 00000000..6b4feef2 --- /dev/null +++ b/src/texturelab/widgets/aboutdialog.cpp @@ -0,0 +1,132 @@ +#include "aboutdialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +AboutDialog::AboutDialog(QWidget* parent) : QDialog(parent) +{ + setWindowTitle("About TextureLab"); + setFixedSize(440, 320); + setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); + setupUI(); +} + +void AboutDialog::setupUI() +{ + setStyleSheet(R"( + QDialog { + background-color: #1e1e1e; + color: #e0e0e0; + } + QLabel { + color: #e0e0e0; + background: transparent; + } + QPushButton#closeBtn { + background-color: #3a3a3a; + color: #e0e0e0; + border: none; + border-radius: 4px; + padding: 6px 20px; + font-size: 13px; + } + QPushButton#closeBtn:hover { + background-color: #4a4a4a; + } + QPushButton#closeBtn:pressed { + background-color: #2a2a2a; + } + )"); + + auto outerLayout = new QVBoxLayout(this); + outerLayout->setContentsMargins(0, 0, 0, 0); + outerLayout->setSpacing(0); + + // Header band + auto header = new QWidget(); + header->setFixedHeight(110); + header->setStyleSheet("background: transparent;"); + + auto headerLayout = new QHBoxLayout(header); + headerLayout->setContentsMargins(28, 0, 28, 0); + headerLayout->setSpacing(20); + + auto logoLabel = new QLabel(); + QPixmap logo(":/icons/logo.png"); + if (!logo.isNull()) { + logoLabel->setPixmap( + logo.scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + } + logoLabel->setFixedSize(64, 64); + headerLayout->addWidget(logoLabel); + + auto titleBlock = new QVBoxLayout(); + titleBlock->setSpacing(4); + + auto nameLabel = new QLabel("TextureLab"); + nameLabel->setStyleSheet( + "color: #ffffff; font-size: 26px; font-weight: bold;"); + titleBlock->addWidget(nameLabel); + + auto tagLabel = new QLabel("Procedural Texture Authoring"); + tagLabel->setStyleSheet("color: #a0b4d0; font-size: 12px;"); + titleBlock->addWidget(tagLabel); + + headerLayout->addLayout(titleBlock); + headerLayout->addStretch(); + + outerLayout->addWidget(header); + + // Body + auto body = new QWidget(); + auto bodyLayout = new QVBoxLayout(body); + bodyLayout->setContentsMargins(28, 22, 28, 20); + bodyLayout->setSpacing(10); + + QString version = QCoreApplication::applicationVersion(); + auto versionLabel = new QLabel(QString("Version %1").arg(version)); + versionLabel->setStyleSheet("font-size: 13px; color: #b0b0b0;"); + bodyLayout->addWidget(versionLabel); + + auto separator = new QFrame(); + separator->setFrameShape(QFrame::HLine); + separator->setStyleSheet("color: #333333;"); + bodyLayout->addWidget(separator); + + auto descLabel = new QLabel( + "A node-based texture creation tool for game artists and developers."); + descLabel->setWordWrap(true); + descLabel->setStyleSheet("font-size: 13px; color: #c0c0c0; line-height: 1.4;"); + bodyLayout->addWidget(descLabel); + + auto linkLabel = new QLabel( + "github.com/njbrown/texturelab"); + linkLabel->setOpenExternalLinks(true); + linkLabel->setStyleSheet("font-size: 12px;"); + bodyLayout->addWidget(linkLabel); + + bodyLayout->addStretch(); + + auto footerLayout = new QHBoxLayout(); + auto copyrightLabel = new QLabel("© Nicolas Brown"); + copyrightLabel->setStyleSheet("font-size: 11px; color: #666666;"); + footerLayout->addWidget(copyrightLabel); + footerLayout->addStretch(); + + auto closeBtn = new QPushButton("Close"); + closeBtn->setObjectName("closeBtn"); + closeBtn->setDefault(true); + connect(closeBtn, &QPushButton::clicked, this, &QDialog::accept); + footerLayout->addWidget(closeBtn); + + bodyLayout->addLayout(footerLayout); + outerLayout->addWidget(body); +} diff --git a/src/texturelab/widgets/aboutdialog.h b/src/texturelab/widgets/aboutdialog.h new file mode 100644 index 00000000..161598c1 --- /dev/null +++ b/src/texturelab/widgets/aboutdialog.h @@ -0,0 +1,16 @@ +#ifndef ABOUTDIALOG_H +#define ABOUTDIALOG_H + +#include + +class AboutDialog : public QDialog { + Q_OBJECT + +public: + explicit AboutDialog(QWidget* parent = nullptr); + +private: + void setupUI(); +}; + +#endif // ABOUTDIALOG_H diff --git a/src/texturelab/widgets/graphwidget.cpp b/src/texturelab/widgets/graphwidget.cpp index b3946925..c7a3e784 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 @@ -9,9 +12,16 @@ #include #include #include +#include #include #include +class NoWheelComboBox : public QComboBox { +public: + using QComboBox::QComboBox; + void wheelEvent(QWheelEvent* event) override { event->ignore(); } +}; + #include "./graphics/texturerenderer.h" #include "./models.h" #include "./utils.h" @@ -23,6 +33,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 +73,40 @@ 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 { + project->addConnection( + project->getNodeById(leftNodeId), + project->getNodeById(rightNodeId), rightInput); + project->getNodeById(rightNodeId)->isDirty = true; + renderer->update(); + } }); connect(graph, &nodegraph::NodeGraph::connectionRemoved, [=](nodegraph::ConnectionPtr con) { - qDebug() << "CONNECTION REMOVED"; - - auto leftNodeId = con->startPort->node->id(); + 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 { + auto con2 = project->removeConnection( + leftNodeId, rightNodeId, rightInput); + if (con2 && con2->rightNode) + con2->rightNode->isDirty = true; + renderer->update(); + } }); connect(graph, &nodegraph::NodeGraph::nodeSelectionChanged, @@ -111,13 +141,79 @@ 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); + for (auto key : project->connections.keys()) { + auto con = project->connections.value(key); + if (con->leftNode->id == n->id() || con->rightNode->id == n->id()) { + if (con->leftNode->id == n->id()) + con->rightNode->isDirty = true; + project->connections.remove(key); + } + } + project->nodes.remove(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::nodeRemoved, - // [=](nodegraph::NodePtr node) { qDebug() << "NODE REMOVED"; }); + connect(graph, &nodegraph::NodeGraph::itemsMoveFinished, + [=](QMap oldPos, QMap newPos) { + if (undoStack) + undoStack->push(new MoveItemsCommand(project, scene, oldPos, newPos)); + }); + + 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; + + auto copyShortcut = new QShortcut(QKeySequence::Copy, this); + copyShortcut->setContext(Qt::WidgetWithChildrenShortcut); + connect(copyShortcut, &QShortcut::activated, this, &GraphWidget::executeCopy); + + auto cutShortcut = new QShortcut(QKeySequence::Cut, this); + cutShortcut->setContext(Qt::WidgetWithChildrenShortcut); + connect(cutShortcut, &QShortcut::activated, this, &GraphWidget::executeCut); + + auto pasteShortcut = new QShortcut(QKeySequence::Paste, this); + pasteShortcut->setContext(Qt::WidgetWithChildrenShortcut); + connect(pasteShortcut, &QShortcut::activated, this, &GraphWidget::executePaste); } void GraphWidget::setupToolbar() @@ -127,7 +223,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 @@ -223,6 +319,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 +345,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,25 +405,58 @@ 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(); + } } void GraphWidget::setTextureRenderer(TextureRenderer* renderer) @@ -317,6 +487,156 @@ 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); + for (auto key : project->connections.keys()) { + auto con = project->connections.value(key); + if (con->leftNode->id == id || con->rightNode->id == id) + project->connections.remove(key); + } + project->nodes.remove(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; + con->rightNode->isDirty = true; + 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 +644,52 @@ 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..d0407dd0 100644 --- a/src/texturelab/widgets/graphwidget.h +++ b/src/texturelab/widgets/graphwidget.h @@ -5,6 +5,7 @@ #include #include #include +#include class QDragEnterEvent; class TextureRenderer; @@ -19,8 +20,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 +34,7 @@ class GraphWidget : public QMainWindow { GraphWidget(); void setTextureProject(TextureProjectPtr project); + void setUndoStack(QUndoStack* stack); void dragEnterEvent(QDragEnterEvent* evt); void dragMoveEvent(QDragMoveEvent* event); @@ -36,6 +42,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 +53,7 @@ class GraphWidget : public QMainWindow { TextureProjectPtr project; TextureRenderer* renderer; + QUndoStack* undoStack = nullptr; protected: void addNode(const TextureNodePtr& node); @@ -58,7 +69,14 @@ class GraphWidget : public QMainWindow { 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..0fe6d781 100644 --- a/src/texturelab/widgets/librarywidget.cpp +++ b/src/texturelab/widgets/librarywidget.cpp @@ -3,11 +3,14 @@ #include "./libraries/library.h" #include +#include +#include #include #include #include #include #include +#include #include #include #include @@ -28,6 +31,24 @@ LibraryWidget::LibraryWidget() : QWidget() 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); + versionLayout->addWidget(versionLabel); + + versionLayout->addStretch(); + + upgradeButton = new QPushButton("Upgrade", versionRow); + upgradeButton->setVisible(false); + connect(upgradeButton, &QPushButton::clicked, + this, &LibraryWidget::upgradeRequested); + versionLayout->addWidget(upgradeButton); + + this->layout()->addWidget(versionRow); + // search box searchBar = new QLineEdit(this); searchBar->setPlaceholderText("search"); @@ -45,6 +66,19 @@ LibraryWidget::LibraryWidget() : QWidget() this->setLibrary(nullptr); } +void LibraryWidget::setLibraryVersion(const QString& version, bool isCurrent) +{ + if (isCurrent) { + versionLabel->setText(QString("Library: %1").arg(version)); + versionLabel->setStyleSheet(""); + } + else { + versionLabel->setText(QString("Library: %1 (outdated)").arg(version)); + versionLabel->setStyleSheet("color: orange;"); + } + upgradeButton->setVisible(!isCurrent); +} + void LibraryWidget::addSpecialItem(const QString& name, const QString& iconPath, PopupItemType type) { 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/properties/accordionwidget.cpp b/src/texturelab/widgets/properties/accordionwidget.cpp new file mode 100644 index 00000000..ca0ff62e --- /dev/null +++ b/src/texturelab/widgets/properties/accordionwidget.cpp @@ -0,0 +1,61 @@ +#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->setStyleSheet( + "QPushButton {" + " background: #333333;" + " color: #cccccc;" + " font-weight: bold;" + " font-size: 12px;" + " text-align: left;" + " padding: 5px 8px;" + " border: none;" + " border-top: 1px solid #444444;" + " border-bottom: 1px solid #444444;" + "}" + "QPushButton:hover {" + " background: #3d3d3d;" + "}"); + 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..f6226efa --- /dev/null +++ b/src/texturelab/widgets/properties/curvepropwidget.cpp @@ -0,0 +1,499 @@ +#include "curvepropwidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +// ============================================================================ +// Colour constants +// ============================================================================ + +static const QColor COL_BG { 0x1a, 0x1a, 0x1a }; +static const QColor COL_GRID { 0x25, 0x25, 0x25 }; +static const QColor COL_IDENTITY { 0x30, 0x30, 0x30 }; +static const QColor COL_CURVE { 0xe0, 0xe0, 0xe0 }; +static const QColor COL_ANCHOR_DEF { 0x88, 0x88, 0x88 }; +static const QColor COL_ANCHOR_HOV { 0xff, 0xff, 0xff }; +static const QColor COL_ANCHOR_SEL { 0x4a, 0x9e, 0xff }; +static const QColor COL_HANDLE_LINE{ 0x55, 0x55, 0x55 }; +static const QColor COL_HANDLE_DOT { 0x88, 0x88, 0x88 }; +static const QColor COL_HANDLE_HOV { 0xcc, 0xcc, 0xcc }; +static const QColor COL_HANDLE_COR { 0xff, 0x99, 0x44 }; // corner (broken) mode + +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); +} + +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(), COL_BG); + + QPen pen(COL_GRID, 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(COL_IDENTITY, 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(COL_CURVE, 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 ? COL_HANDLE_DOT : COL_HANDLE_COR; + + // Lines from anchor to handles + QPen linePen(COL_HANDLE_LINE, 1); + p.setPen(linePen); + p.drawLine(anchor, lh); + p.drawLine(anchor, rh); + + // Handle dots + auto drawHandle = [&](QPointF pos, bool isHovered) { + QColor c = isHovered ? COL_HANDLE_HOV : 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 = COL_ANCHOR_SEL; + // ring + p.setPen(QPen(COL_ANCHOR_SEL, 1)); + p.setBrush(Qt::NoBrush); + p.drawEllipse(wp, r + 2, r + 2); + } else if (i == hoveredPoint) { + r = ANCHOR_R_HL; + fill = COL_ANCHOR_HOV; + } else { + r = ANCHOR_R; + fill = COL_ANCHOR_DEF; + } + + 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->setStyleSheet("font-size: 10px;"); + + 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->setStyleSheet("color: #888; font-size: 10px;"); + 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..be93a5e2 --- /dev/null +++ b/src/texturelab/widgets/properties/curvepropwidget.h @@ -0,0 +1,78 @@ +#pragma once + +#include "../../curve.h" +#include "../../props.h" + +#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); +}; + +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..14604280 100644 --- a/src/texturelab/widgets/properties/propertieswidget.cpp +++ b/src/texturelab/widgets/properties/propertieswidget.cpp @@ -1,9 +1,13 @@ #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() { @@ -12,7 +16,7 @@ PropertiesWidget::PropertiesWidget() : QWidget() 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 +31,184 @@ 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). +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, nullptr, propName, oldVal, newVal)); + // renderer=nullptr: PropertiesWidget doesn't hold the renderer; + // markNodeAsDirty already triggers re-render via the renderer's update loop. +} + +QWidget* PropertiesWidget::createPropWidget(Prop* prop, + const TextureNodePtr& node) +{ + 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 = prop->getValue(); + node->setProp(prop->name, value); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, value); + pushPropChange(undoStack, node, project, nullptr, 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 = prop->getValue(); + node->setProp(prop->name, value); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, value); + pushPropChange(undoStack, node, project, nullptr, 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 = prop->getValue(); + node->setProp(prop->name, (int)value); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, (int)value); + pushPropChange(undoStack, node, project, nullptr, 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 = prop->getValue(); + node->setProp(prop->name, value); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, value); + pushPropChange(undoStack, node, project, nullptr, 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 = prop->getValue(); + node->setProp(prop->name, value); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, value); + pushPropChange(undoStack, node, project, nullptr, 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 oldVal = prop->getValue(); + QVariant newVal = QVariant::fromValue(value); + node->setProp(prop->name, newVal); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, newVal); + pushPropChange(undoStack, node, project, nullptr, 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 = prop->getValue(); + node->setProp(prop->name, value); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, value); + pushPropChange(undoStack, node, project, nullptr, 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 = prop->getValue(); + node->setProp(prop->name, value); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, value); + pushPropChange(undoStack, node, project, nullptr, 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 oldVal = prop->getValue(); + QVariant newVal = QVariant::fromValue(value); + node->setProp(prop->name, newVal); + project->markNodeAsDirty(node); + emit propertyUpdated(prop->name, newVal); + pushPropChange(undoStack, node, project, nullptr, 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 +246,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, nullptr, 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->setStyleSheet("font-weight: bold; margin-bottom: 4px;"); + 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->setStyleSheet("font-weight: bold; margin-bottom: 4px;"); + 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(); @@ -231,4 +389,14 @@ void PropertiesWidget::clearSelection() void PropertiesWidget::setProject(const TextureProjectPtr& project) { this->project = project; +} + +void PropertiesWidget::setScene(NgScenePtr ngScene) +{ + scene = ngScene; +} + +void PropertiesWidget::setUndoStack(QUndoStack* stack) +{ + undoStack = stack; } \ No newline at end of file diff --git a/src/texturelab/widgets/properties/propertieswidget.h b/src/texturelab/widgets/properties/propertieswidget.h index 518729bd..a6255245 100644 --- a/src/texturelab/widgets/properties/propertieswidget.h +++ b/src/texturelab/widgets/properties/propertieswidget.h @@ -1,13 +1,23 @@ #pragma once +#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; @@ -23,7 +33,11 @@ class PropertiesWidget : public QWidget { QVector propWidgets; TextureProjectPtr project; + NgScenePtr scene; + QUndoStack* undoStack = nullptr; TextureNodePtr selectedNode; + FramePtr selectedFrame; + CommentPtr selectedComment; // base props EnumProp* textureChannelProp; @@ -33,15 +47,22 @@ class PropertiesWidget : public QWidget { 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); private: void addBasePropsToLayout(); + QWidget* createPropWidget(Prop* prop, const TextureNodePtr& node); 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..c1d92222 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() @@ -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; } @@ -523,6 +588,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..e1bf7c14 100644 --- a/src/texturelab/widgets/view2dwidget.cpp +++ b/src/texturelab/widgets/view2dwidget.cpp @@ -81,7 +81,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() { @@ -315,20 +319,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 +342,7 @@ void View2DGraph::mouseMoveEvent(QMouseEvent* event) void View2DGraph::mouseReleaseEvent(QMouseEvent* event) { if (event->button() == Qt::MiddleButton) { + setDragMode(QGraphicsView::ScrollHandDrag); } QGraphicsView::mouseReleaseEvent(event); } @@ -349,7 +356,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) { @@ -438,7 +450,13 @@ void NodePreviewGraphicsItem::initializeGL() out vec4 fragColor; uniform sampler2D textureSampler; 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(vec3(0.753), vec3(0.502), checker); + + vec4 texColor = texture(textureSampler, vTexCoord); + fragColor = vec4(mix(bg, texColor.rgb, texColor.a), 1.0); } )"; diff --git a/src/texturelab/widgets/view3dwidget.cpp b/src/texturelab/widgets/view3dwidget.cpp index e0416070..5c84b389 100644 --- a/src/texturelab/widgets/view3dwidget.cpp +++ b/src/texturelab/widgets/view3dwidget.cpp @@ -9,7 +9,7 @@ View3DWidget::View3DWidget() this->viewer = new Viewer3D(); this->setCentralWidget(viewer); - this->viewer->setDefaultEnvironment(":env/cave_wall_1k.hdr"); + this->viewer->setDefaultEnvironment(":env/sunny_rose_garden_1k.hdr"); // Create menu bar QMenuBar* menuBar = new QMenuBar(this); 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/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..523e14ec 100644 --- a/src/viewer3d/renderer/renderer.cpp +++ b/src/viewer3d/renderer/renderer.cpp @@ -91,6 +91,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 +112,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"; @@ -297,28 +301,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); diff --git a/src/viewer3d/renderer/renderer.h b/src/viewer3d/renderer/renderer.h index 7857aa5f..ebb5b68d 100644 --- a/src/viewer3d/renderer/renderer.h +++ b/src/viewer3d/renderer/renderer.h @@ -64,6 +64,8 @@ struct Material { GLuint metalnessMapId = 0; GLuint roughnessMapId = 0; GLuint heightMapId = 0; + GLuint aoMapId = 0; + GLuint alphaMapId = 0; // QOpenGLTexture* albedoMap = nullptr; // QOpenGLTexture* normalMap = nullptr; diff --git a/src/viewer3d/viewer3d.cpp b/src/viewer3d/viewer3d.cpp index cb108884..804cb287 100644 --- a/src/viewer3d/viewer3d.cpp +++ b/src/viewer3d/viewer3d.cpp @@ -85,7 +85,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 @@ -111,7 +111,7 @@ void Viewer3D::initializeGL() if (!defaultEnvPath.isEmpty()) renderer->loadEnvironment(defaultEnvPath); else - renderer->loadEnvironment(":assets/panorama.hdr"); + renderer->loadEnvironment(":env/sunny_rose_garden_1k.hdr"); } void Viewer3D::setDefaultEnvironment(const QString path) @@ -131,23 +131,34 @@ void Viewer3D::paintGL() 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 + // 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); + + 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; // float roughness = 0; @@ -466,6 +477,8 @@ void Viewer3D::setAlbedoTexture(GLuint texId) void Viewer3D::clearAlbedoTexture() { + if (!this->material) + return; this->material->albedoMapId = 0; this->material->needsUpdate = true; } @@ -478,6 +491,8 @@ void Viewer3D::setNormalTexture(GLuint texId) void Viewer3D::clearNormalTexture() { + if (!this->material) + return; this->material->normalMapId = 0; this->material->needsUpdate = true; } @@ -490,6 +505,8 @@ void Viewer3D::setMetalnessTexture(GLuint texId) void Viewer3D::clearMetalnessTexture() { + if (!this->material) + return; this->material->metalnessMapId = 0; this->material->needsUpdate = true; } @@ -502,6 +519,8 @@ void Viewer3D::setRoughnessTexture(GLuint texId) void Viewer3D::clearRoughnessTexture() { + if (!this->material) + return; this->material->roughnessMapId = 0; this->material->needsUpdate = true; } @@ -514,6 +533,8 @@ void Viewer3D::setHeightTexture(GLuint texId) void Viewer3D::clearHeightTexture() { + if (!this->material) + return; this->material->heightMapId = 0; this->material->needsUpdate = true; } @@ -524,6 +545,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 +580,8 @@ void Viewer3D::clearTextures() this->clearMetalnessTexture(); this->clearRoughnessTexture(); this->clearHeightTexture(); + this->clearAoTexture(); + this->clearAlphaTexture(); } void Viewer3D::resetCamera() @@ -563,27 +614,30 @@ void Viewer3D::setModel(const QString& modelType) // Create new mesh based on type if (modelType == "sphere") { - gltfMesh = createSphere(this->gl, 2, 64, 64); + gltfMesh = 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); + gltfMesh = + 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); + gltfMesh = + 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); + gltfMesh = + 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); + gltfMesh = 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); + gltfMesh = createCube(this->gl, 2, 2, 2, 1000, 1000, 1000); } else if (modelType == "cubesphere") { // CubeSphere - a sphere with low segments for a more cubic look @@ -591,7 +645,7 @@ void Viewer3D::setModel(const QString& modelType) } else { // Default to sphere - gltfMesh = createSphere(this->gl, 2, 64, 64); + gltfMesh = createSphere(this->gl, 2, 1000, 1000); } // Release OpenGL context diff --git a/src/viewer3d/viewer3d.h b/src/viewer3d/viewer3d.h index e2013e04..8145034d 100644 --- a/src/viewer3d/viewer3d.h +++ b/src/viewer3d/viewer3d.h @@ -106,13 +106,12 @@ 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);