diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..0f8b652 --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,52 @@ +# clang-tidy configuration for vv. +# +# Pragmatic check set for a Qt + VTK C++17 codebase: catch real bugs and obvious +# modernization/perf wins, but silence checks that fight the frameworks (VTK's +# vtkNew/raw-pointer idioms, Qt's parent-owned `new`, C-array VTK APIs). +Checks: > + clang-analyzer-*, + bugprone-*, + performance-*, + portability-*, + modernize-*, + readability-*, + -bugprone-easily-swappable-parameters, + -bugprone-narrowing-conversions, + -modernize-use-trailing-return-type, + -modernize-avoid-c-arrays, + -modernize-use-nodiscard, + -readability-magic-numbers, + -readability-identifier-length, + -readability-function-cognitive-complexity, + -readability-braces-around-statements, + -readability-implicit-bool-conversion, + -readability-named-parameter, + -readability-uppercase-literal-suffix, + -readability-math-missing-parentheses, + -portability-avoid-pragma-once, + -performance-enum-size, + -modernize-return-braced-init-list, + -modernize-use-emplace, + -modernize-use-equals-default, + -modernize-loop-convert, + -modernize-avoid-c-style-cast, + -modernize-use-auto, + -readability-isolate-declaration, + -readability-qualified-auto, + -readability-simplify-boolean-expr, + -readability-use-std-min-max, + -readability-container-size-empty, + -readability-use-concise-preprocessor-directives, + -readability-redundant-casting, + -bugprone-float-loop-counter, + -clang-analyzer-security.FloatLoopCounter, + -performance-inefficient-string-concatenation + +# Only diagnose our own headers, not VTK/Qt system headers. +HeaderFilterRegex: 'src/include/.*\.h$' + +# Real bug-class findings fail CI; modernize/readability stay advisory so a newer +# clang-tidy adding stricter style checks doesn't unexpectedly break the build. +WarningsAsErrors: 'bugprone-*,clang-analyzer-*,performance-*,portability-*' + +FormatStyle: file diff --git a/.cppcheck-suppressions b/.cppcheck-suppressions new file mode 100644 index 0000000..4a58ab4 --- /dev/null +++ b/.cppcheck-suppressions @@ -0,0 +1,24 @@ +# cppcheck suppressions for vv. +# Format: [:[:]] — see `cppcheck --help`. + +# Generated Qt moc/autogen translation units live under build/ and are not our +# code. They trip preprocessorErrorDirective ("doesn't include ") and +# similar because cppcheck evaluates them without Qt's moc context. +*:build/* +*:*/build/* +*:*moc_*.cpp +*:*mocs_compilation.cpp + +# Public widget/API methods (e.g. PlaybackBar::isPlaying) are intentionally part +# of the class surface even when the current callers don't use them all. +unusedFunction + +# Third-party headers we cannot fix; not worth the noise. +missingIncludeSystem + +# cppcheck's own informational notices. +checkersReport + +# Subjective style rule: a short, clear raw loop is often more readable than an +# call with a lambda. We don't enforce this. +useStlAlgorithm diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c505f43..90ddc6c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,163 +14,62 @@ concurrency: env: CI: true +# Fast quality gate: format, a warnings-as-errors build, and cppcheck — all on a +# single Linux runner with ccache. Multi-OS packaging runs only on release tags +# (see release.yml), so PRs are not blocked on slow cross-platform builds. jobs: quality: runs-on: ubuntu-22.04 - name: Format + Warning-Clean Build - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install Linux dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - build-essential \ - cmake \ - ninja-build \ - clang-format \ - qtbase5-dev \ - libfmt-dev \ - libcxxopts-dev \ - libvtk9-dev \ - libvtk9-qt-dev \ - pkg-config \ - bison \ - flex \ - libxmu-dev \ - libxi-dev \ - libgl-dev \ - libxt-dev \ - libsm-dev \ - libice-dev \ - libxext-dev \ - libxrender-dev \ - libxrandr-dev \ - libxcursor-dev \ - libxinerama-dev \ - libx11-dev \ - mesa-common-dev \ - freeglut3-dev - - - name: Configure CMake - run: | - cmake -Bbuild -S. \ - -DCMAKE_BUILD_TYPE=Release \ - -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ - -DVV_ENABLE_WARNINGS=ON \ - -DVV_WARNINGS_AS_ERRORS=ON \ - -GNinja - - - name: Check formatting - run: | - cmake --build build --target format - git diff --exit-code - - - name: Build - run: cmake --build build --config Release -j$(nproc) - - - name: Test binary - run: | - file build/vv - ldd build/vv || true - ./build/vv --help - - release-os-build: - name: Release OS Build ${{ matrix.target }} - needs: quality - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - include: - - target: x86_64-unknown-linux-gnu - os: ubuntu-22.04 - - target: x86_64-w64-mingw32 - os: windows-latest - - target: x86_64-apple-darwin - os: macos-15-intel - - target: aarch64-apple-darwin - os: macos-14 - + name: Format + Warnings + cppcheck steps: - name: Checkout code uses: actions/checkout@v4 - - name: Install Linux dependencies - if: runner.os == 'Linux' + - name: Install dependencies run: | sudo apt-get update - sudo apt-get install -y \ - build-essential \ - cmake \ - ninja-build \ - qtbase5-dev \ - libvtk9-dev \ - libvtk9-qt-dev \ - libfmt-dev \ - libcxxopts-dev - - - name: Install macOS dependencies - if: runner.os == 'macOS' - run: | - brew update - for pkg in cmake ninja vtk qt fmt cxxopts; do - brew list --versions "$pkg" >/dev/null || brew install "$pkg" - done - - - name: Set up MSYS2 (Windows) - if: runner.os == 'Windows' - uses: msys2/setup-msys2@v2 + sudo apt-get install -y --no-install-recommends \ + build-essential cmake ninja-build ccache clang-format clang-tidy cppcheck \ + qtbase5-dev libfmt-dev libcxxopts-dev nlohmann-json3-dev \ + libvtk9-dev libvtk9-qt-dev \ + libgl-dev libglx-dev libxt-dev + + - name: ccache + uses: hendrikmuhs/ccache-action@v1.2 with: - msystem: MINGW64 - update: true - install: >- - mingw-w64-x86_64-gcc - mingw-w64-x86_64-cmake - mingw-w64-x86_64-ninja - mingw-w64-x86_64-qt6-base - mingw-w64-x86_64-vtk - mingw-w64-x86_64-nlohmann-json + key: ccache-${{ github.workflow }}-ubuntu-22.04 + max-size: 500M - - name: Configure CMake (Linux) - if: runner.os == 'Linux' + - name: Configure run: | - cmake -S . -B build \ - -GNinja \ + cmake -S . -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ + -DCMAKE_C_COMPILER_LAUNCHER=ccache \ + -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ -DVV_ENABLE_WARNINGS=ON \ -DVV_WARNINGS_AS_ERRORS=ON - - name: Configure CMake (macOS) - if: runner.os == 'macOS' - run: | - QT_PREFIX="$(brew --prefix qt)" - VTK_PREFIX="$(brew --prefix vtk)" - cmake -S . -B build \ - -GNinja \ - -DCMAKE_BUILD_TYPE=Release \ - -DVV_ENABLE_WARNINGS=ON \ - -DVV_WARNINGS_AS_ERRORS=ON \ - -DCMAKE_PREFIX_PATH="$QT_PREFIX;$VTK_PREFIX" - - - name: Configure CMake (Windows) - if: runner.os == 'Windows' - shell: msys2 {0} + - name: Check formatting run: | - cmake -S . -B build \ - -GNinja \ - -DCMAKE_BUILD_TYPE=Release \ - -DVV_ENABLE_WARNINGS=ON \ - -DVV_WARNINGS_AS_ERRORS=ON \ - -DCMAKE_PREFIX_PATH="/mingw64" - - - name: Build (Linux/macOS) - if: runner.os != 'Windows' - run: cmake --build build --config Release -j4 - - - name: Build (Windows) - if: runner.os == 'Windows' - shell: msys2 {0} - run: cmake --build build --config Release -j4 + cmake --build build --target format + git diff --exit-code + + - name: Build (warnings as errors) + run: cmake --build build -j"$(nproc)" + + # Static analysis via build.sh so local and CI share the exact same flags, + # suppressions, and version probes (build already done above; --no-build + # just regenerates compile_commands and runs the analyzer). + - name: cppcheck (gate) + run: ./build.sh --no-build --cppcheck + + # clang-tidy is advisory in CI: the runner's clang-tidy version differs from + # developers' and can surface version-specific findings, so it reports + # without blocking the merge. `build.sh --analyze` gates it locally. + - name: clang-tidy (advisory) + continue-on-error: true + run: ./build.sh --no-build --clang-tidy + + - name: Smoke test + run: ./build/vv --version diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1fdc605..03a9b44 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,10 +4,13 @@ on: push: tags: - 'v*' + workflow_dispatch: env: CI: true +# Builds self-contained, deployable bundles (Qt + VTK included) for each OS, plus +# the bare binary for users who already have the runtime via a package manager. jobs: build: name: Build ${{ matrix.target }} @@ -18,43 +21,39 @@ jobs: include: - target: x86_64-unknown-linux-gnu os: ubuntu-22.04 - archive: tar.gz - artifact_name: vv - - target: x86_64-w64-mingw32 - os: windows-latest - archive: zip - artifact_name: vv.exe - target: x86_64-apple-darwin os: macos-15-intel - archive: tar.gz - artifact_name: vv - target: aarch64-apple-darwin os: macos-14 - archive: tar.gz - artifact_name: vv + - target: x86_64-windows + os: windows-latest steps: - uses: actions/checkout@v4 + - name: ccache + if: runner.os != 'Windows' + uses: hendrikmuhs/ccache-action@v1.2 + with: + key: ccache-release-${{ matrix.target }} + max-size: 500M + + # ── dependencies ─────────────────────────────────────────────── - name: Install Linux dependencies if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y \ - build-essential \ - cmake \ - ninja-build \ - qtbase5-dev \ - libvtk9-dev \ - libvtk9-qt-dev \ - libfmt-dev \ - libcxxopts-dev + sudo apt-get install -y --no-install-recommends \ + build-essential cmake ninja-build ccache imagemagick file \ + qtbase5-dev libfmt-dev libcxxopts-dev nlohmann-json3-dev \ + libvtk9-dev libvtk9-qt-dev \ + libgl-dev libglx-dev libxt-dev libfuse2 - name: Install macOS dependencies if: runner.os == 'macOS' run: | brew update - for pkg in cmake ninja vtk qt fmt cxxopts; do + for pkg in cmake ninja vtk qt fmt cxxopts nlohmann-json; do brew list --versions "$pkg" >/dev/null || brew install "$pkg" done @@ -68,83 +67,113 @@ jobs: mingw-w64-x86_64-gcc mingw-w64-x86_64-cmake mingw-w64-x86_64-ninja + mingw-w64-x86_64-ccache mingw-w64-x86_64-qt6-base mingw-w64-x86_64-vtk + mingw-w64-x86_64-fmt + mingw-w64-x86_64-cxxopts mingw-w64-x86_64-nlohmann-json - - name: Configure CMake (Linux) + # ── configure + build ────────────────────────────────────────── + - name: Configure + build (Linux) if: runner.os == 'Linux' run: | - cmake -S . -B build \ - -GNinja \ + cmake -S . -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ - -DVV_ENABLE_WARNINGS=ON \ - -DVV_WARNINGS_AS_ERRORS=ON + -DCMAKE_C_COMPILER_LAUNCHER=ccache \ + -DCMAKE_CXX_COMPILER_LAUNCHER=ccache + cmake --build build -j"$(nproc)" - - name: Configure CMake (macOS) + - name: Configure + build (macOS) if: runner.os == 'macOS' run: | - QT_PREFIX="$(brew --prefix qt)" - VTK_PREFIX="$(brew --prefix vtk)" - cmake -S . -B build \ - -GNinja \ + cmake -S . -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ - -DVV_ENABLE_WARNINGS=ON \ - -DVV_WARNINGS_AS_ERRORS=ON \ - -DCMAKE_PREFIX_PATH="$QT_PREFIX;$VTK_PREFIX" + -DCMAKE_C_COMPILER_LAUNCHER=ccache \ + -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ + -DCMAKE_PREFIX_PATH="$(brew --prefix qt);$(brew --prefix vtk)" + cmake --build build -j"$(sysctl -n hw.ncpu)" - - name: Configure CMake (Windows) + - name: Configure + build (Windows) if: runner.os == 'Windows' shell: msys2 {0} run: | - cmake -S . -B build \ - -GNinja \ + cmake -S . -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ - -DVV_ENABLE_WARNINGS=ON \ - -DVV_WARNINGS_AS_ERRORS=ON \ + -DCMAKE_C_COMPILER_LAUNCHER=ccache \ + -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ -DCMAKE_PREFIX_PATH="/mingw64" + cmake --build build -j"$(nproc)" - - name: Build (Linux/macOS) - if: runner.os != 'Windows' - run: cmake --build build --config Release -j4 + # ── package: deployable bundle ───────────────────────────────── + - name: Package Linux AppImage + if: runner.os == 'Linux' + run: | + set -e + cmake --install build --prefix AppDir/usr + mkdir -p AppDir/usr/share/applications \ + AppDir/usr/share/icons/hicolor/256x256/apps + printf '%s\n' \ + '[Desktop Entry]' 'Type=Application' 'Name=vv' \ + 'GenericName=Mesh Viewer' 'Exec=vv %F' 'Icon=vv' \ + 'Categories=Graphics;Science;' 'Terminal=false' \ + > AppDir/usr/share/applications/vv.desktop + convert -size 256x256 xc:'#1e1e1e' \ + -gravity center -pointsize 120 -fill '#5096FA' -annotate 0 'vv' \ + AppDir/usr/share/icons/hicolor/256x256/apps/vv.png + wget -q https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage + wget -q https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage + chmod +x linuxdeploy*.AppImage + export QMAKE=/usr/bin/qmake + export OUTPUT="vv-${GITHUB_REF_NAME}-${{ matrix.target }}.AppImage" + ./linuxdeploy-x86_64.AppImage --appdir AppDir --plugin qt --output appimage + mkdir -p dist && mv vv*.AppImage dist/ + # bare binary too + tar -czf "dist/vv-${GITHUB_REF_NAME}-${{ matrix.target }}-bin.tar.gz" -C build vv + + - name: Package macOS app + if: runner.os == 'macOS' + run: | + set -e + MACDEPLOYQT="$(brew --prefix qt)/bin/macdeployqt" + "$MACDEPLOYQT" build/vv.app -verbose=1 + mkdir -p dist + ditto -c -k --keepParent build/vv.app \ + "dist/vv-${GITHUB_REF_NAME}-${{ matrix.target }}.app.zip" + tar -czf "dist/vv-${GITHUB_REF_NAME}-${{ matrix.target }}-bin.tar.gz" \ + -C build/vv.app/Contents/MacOS vv - - name: Build (Windows) + - name: Package Windows zip if: runner.os == 'Windows' shell: msys2 {0} - run: cmake --build build --config Release -j4 - - - name: Package (tar.gz) - if: matrix.archive == 'tar.gz' run: | + set -e + STAGE="vv-${GITHUB_REF_NAME}-${{ matrix.target }}" + mkdir -p "stage/$STAGE" + cp build/vv.exe "stage/$STAGE/" + # windeployqt copies Qt DLLs + plugins (platforms/, styles/, …) into the + # staging dir alongside the exe. + windeployqt --no-compiler-runtime "stage/$STAGE/vv.exe" + # Then copy the remaining MinGW/VTK runtime DLLs the binary links against. + ldd "stage/$STAGE/vv.exe" | awk '/mingw64/ {print $3}' | sort -u | \ + while read -r dll; do cp -n "$dll" "stage/$STAGE/" 2>/dev/null || true; done mkdir -p dist - tar -czvf "dist/vv-${{ github.ref_name }}-${{ matrix.target }}.tar.gz" -C build ${{ matrix.artifact_name }} + (cd stage && powershell -Command "Compress-Archive -Path '$STAGE' -DestinationPath '../dist/$STAGE.zip'") - - name: Package (zip) - if: matrix.archive == 'zip' - shell: pwsh - run: | - New-Item -ItemType Directory -Force -Path dist | Out-Null - $binary = "build/vv.exe" - if (!(Test-Path $binary)) { - $binary = "build/Release/vv.exe" - } - Compress-Archive -Path $binary -DestinationPath "dist/vv-${{ github.ref_name }}-${{ matrix.target }}.zip" - - - name: Upload artifact + - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: vv-${{ matrix.target }} - path: dist/vv-${{ github.ref_name }}-${{ matrix.target }}.* + path: dist/* release: name: Create Release needs: build + if: startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest permissions: contents: write steps: - - uses: actions/checkout@v4 - - name: Download all artifacts uses: actions/download-artifact@v4 with: diff --git a/.gitignore b/.gitignore index 35fbd61..d5074b2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,8 @@ .vscode/ build/ .cache/ - -# Created by https://www.toptal.com/developers/gitignore/api/macos,linux,c++,ninja,cmake -# Edit at https://www.toptal.com/developers/gitignore?templates=macos,linux,c++,ninja,cmake +# Created by https://www.toptal.com/developers/gitignore/api/macos,linux,c++,ninja,cmake,windows,vcpkg +# Edit at https://www.toptal.com/developers/gitignore?templates=macos,linux,c++,ninja,cmake,windows,vcpkg ### C++ ### # Prerequisites @@ -45,8 +44,7 @@ CMakeCache.txt CMakeFiles CMakeScripts Testing -**/Makefile -!Makefile +Makefile cmake_install.cmake install_manifest.txt compile_commands.json @@ -111,4 +109,38 @@ Temporary Items .ninja_deps .ninja_log -# End of https://www.toptal.com/developers/gitignore/api/macos,linux,c++,ninja,cmake +### vcpkg ### +# Vcpkg + +vcpkg-manifest-install.log + +## Manifest Mode +vcpkg_installed/ + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/macos,linux,c++,ninja,cmake,windows,vcpkg \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c62a862 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,66 @@ +# [1.2.0] - 2026-06-13 + +### Added + +- Cell-data scalar support: Space now cycles point- **and** cell-data fields + (cell fields labelled `… (cells)`), in both the single view and the + `--explode` facet grid. +- VTKHDF parser with temporal (time-series) playback: a media bar with + play/pause, scrub slider, speed multiplier, and loop; color range fixed + across the animation so the colormap stays stable. +- `build.sh`: single entry point to configure, build, run static analysis + (`--cppcheck`, `--clang-tidy`, `--analyze`), check formatting, and install. +- `.clang-tidy` and `.cppcheck-suppressions`: high-signal analysis configs + that filter Qt `moc`/autogen false positives; bug-class findings gated. +- Vector-drawn playback icons (no dependency on system Unicode media glyphs). + +### Changed + +- `main()` refactored into a `ViewerWindow` `QMainWindow`; `main_qt.cpp` + retains only CLI/platform setup. +- Release workflow now ships self-contained bundles (Linux AppImage, macOS + `vv.app` via `macdeployqt`, Windows zip via `windeployqt`) plus bare + binaries. CI trimmed to a fast cached Linux quality gate. +- `install.sh` replaced by `./build.sh --install`. + +### Fixed + +- Hardened mesh loading: `std::filesystem` existence/removal, atomic temp-file + creation, LS-DYNA `*INCLUDE` cycle guard, FreeSurfer header/size/index + validation, and caught `cxxopts` parse errors. +- Legacy VTK unstructured grids (e.g. ParaView-clipped meshes) now read. + +# [1.1.0] - 2026-04-24 + +### Added + +- Interactive Qt frontend replacing the old headless VTK window (`main_qt.cpp`). +- Parts tree overlay widget: toggle per-part and per-group visibility with tri-state checkboxes. +- Color bar overlay widget rendered directly on the VTK canvas. +- Exploded facet-grid view (`--explode`): multiple inputs displayed as a grid of viewports, each with its own color bar and clip range. +- Scalar cycling with Space bar (cycles through all point-data arrays including a "none" state). +- LS-DYNA mesh parser (`LSDynaMeshParser`) supporting keyword-format `.k` / `.key` files. +- Windows build support via vcpkg and MSVC (`win-x64-release` / `win-x64-debug` CMake presets). +- Automatic Qt plugin deployment on Windows post-build (vcpkg tree copy or `windeployqt` fallback). +- GitHub Actions release workflow producing signed Windows and Linux binaries. +- GitHub Actions Docker workflow. +- Cached vcpkg dependencies in CI. +- `clang-format` target and `.clang-format` configuration (LLVM base, 100-column limit). +- CMake configure log now prints detected VTK version on success. + +### Fixed + +- Missing `#include ` in `MeshParser.h`, required by VTK 9.1 on Linux where VTK headers no longer pull it in transitively. +- CMake VTK-not-found error now mentions `libvtk9-qt-dev` (required for `GUISupportQt` on Ubuntu/Debian). + +# [1.0.5] - 2025-10-29 + +### Added + +- Multi-mesh support in exploded view mode. +- CI/CD pipeline. +- FreeSurfer surface mesh parser (`FSurfMeshParser`). +- CARTO mesh parser (`CartoMeshParser`). +- Build date embedded in version string. +- Install script (`install.sh`) copying the binary to `~/.local/bin`. +- Versioning via `version.h.in`. diff --git a/CMakeLists.txt b/CMakeLists.txt index 9db9ee9..4411084 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,12 +1,13 @@ cmake_minimum_required(VERSION 3.14) -project(VtkViewer VERSION 1.1.0) +project(VtkViewer VERSION 1.2.0) set(CMAKE_AUTOMOC ON) option(VV_FETCH_DEPS "Fetch fmt and cxxopts with CMake when missing" ON) option(VV_ENABLE_WARNINGS "Enable strict compiler warnings" ON) option(VV_WARNINGS_AS_ERRORS "Treat warnings as errors" ON) +option(VV_QT_WINDOWS_DEPLOY "Run Qt windeployqt after vv links (Windows)" ON) if(VV_FETCH_DEPS) include(FetchContent) @@ -67,6 +68,8 @@ find_package(VTK QUIET COMPONENTS CommonColor IOLegacy IOXML + IOImage + IOHDF FiltersCore RenderingCore RenderingOpenGL2 @@ -79,10 +82,13 @@ find_package(VTK QUIET COMPONENTS if(NOT VTK_FOUND) message(FATAL_ERROR "VTK was not found. Install VTK development files and reconfigure. " - "Examples: macOS (brew): brew install vtk; Ubuntu/Debian: sudo apt install libvtk9-dev. " + "Examples: macOS (brew): brew install vtk; " + "Ubuntu/Debian: sudo apt install libvtk9-dev libvtk9-qt-dev " + "(libvtk9-qt-dev is required for GUISupportQt). " "If VTK is installed in a non-standard location, set CMAKE_PREFIX_PATH or VTK_DIR." ) endif() +message(STATUS "VTK ${VTK_VERSION} found") if(TARGET Qt5::Core) set(QT_VERSION_MAJOR 5) @@ -95,15 +101,52 @@ else() find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets) endif() -file(GLOB VV_ALL_SOURCES "src/*.cpp") -set(VV_CORE_SOURCES ${VV_ALL_SOURCES}) -list(REMOVE_ITEM VV_CORE_SOURCES - "${CMAKE_CURRENT_SOURCE_DIR}/src/main_qt.cpp" +set(VV_CORE_SOURCES + src/CartoMeshParser.cpp + src/ColorBarWidget.cpp + src/FSurfMeshParser.cpp + src/JsonMeshParser.cpp + src/LSDynaMeshParser.cpp + src/MeshLoading.cpp + src/MeshParser.cpp + src/MeshRenderer.cpp + src/PlaybackBar.cpp + src/ScalarVizUtils.cpp + src/TemporalSource.cpp + src/VTKHDFMeshParser.cpp + src/VTKMeshParser.cpp + src/ViewerWindow.cpp + src/XMLMeshParser.cpp + src/mesh_utils.cpp ) -add_executable(vv src/main_qt.cpp ${VV_CORE_SOURCES} src/include/ColorBarWidget.h) +set(VV_HEADERS + src/include/CartoMeshParser.h + src/include/ColorBarWidget.h + src/include/FSurfMeshParser.h + src/include/JsonMeshParser.h + src/include/LSDynaMeshParser.h + src/include/MeshLoading.h + src/include/MeshParser.h + src/include/MeshRenderer.h + src/include/PlaybackBar.h + src/include/ScalarVizUtils.h + src/include/TemporalSource.h + src/include/VTKHDFMeshParser.h + src/include/VTKMeshParser.h + src/include/ViewerWindow.h + src/include/XMLMeshParser.h + src/include/mesh_utils.h +) + +add_executable(vv src/main_qt.cpp ${VV_CORE_SOURCES} ${VV_HEADERS}) target_include_directories(vv PRIVATE src/include) +if(MSVC) + target_compile_options(vv PRIVATE /EHsc) + target_compile_definitions(vv PRIVATE _CRT_SECURE_NO_WARNINGS) +endif() + if(VV_ENABLE_WARNINGS) if(MSVC) target_compile_options(vv PRIVATE /W4) @@ -119,6 +162,9 @@ if(VV_ENABLE_WARNINGS) -Wformat=2 -Wnull-dereference -Wdouble-promotion + -Wconversion + -Wsign-conversion + -Wimplicit-fallthrough ) if(VV_WARNINGS_AS_ERRORS) target_compile_options(vv PRIVATE -Werror) @@ -127,12 +173,15 @@ if(VV_ENABLE_WARNINGS) endif() target_link_libraries(vv PRIVATE fmt::fmt) +target_link_libraries(vv PRIVATE nlohmann_json::nlohmann_json) target_link_libraries(vv PRIVATE VTK::CommonCore VTK::CommonDataModel VTK::CommonColor VTK::IOLegacy VTK::IOXML + VTK::IOImage + VTK::IOHDF VTK::FiltersCore VTK::RenderingCore VTK::RenderingOpenGL2 @@ -148,6 +197,57 @@ vtk_module_autoinit( MODULES ${VTK_LIBRARIES} ) +# Windows + Qt: put plugins (platforms/qwindows.dll, etc.) next to vv.exe. +# vcpkg: windeployqt does not understand vcpkg's Qt layout ("Unable to find the platform plugin"); +# copy the installed plugins tree instead. Non-vcpkg Qt: windeployqt is the usual tool. +if(WIN32 AND VV_QT_WINDOWS_DEPLOY AND QT_VERSION_MAJOR EQUAL 6) + if(DEFINED VCPKG_INSTALLED_DIR AND DEFINED VCPKG_TARGET_TRIPLET) + # vcpkg qtbase installs plugin roots as /Qt6/plugins (see tools/Qt6/bin/qt.conf), not /plugins. + set(_vv_vcpkg_root "${VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}") + if(EXISTS "${_vv_vcpkg_root}/Qt6/plugins") + set(_vv_qt_plugins "${_vv_vcpkg_root}/Qt6/plugins") + elseif(EXISTS "${_vv_vcpkg_root}/plugins") + set(_vv_qt_plugins "${_vv_vcpkg_root}/plugins") + else() + set(_vv_qt_plugins "${_vv_vcpkg_root}/Qt6/plugins") + endif() + add_custom_command( + TARGET vv + POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory "${_vv_qt_plugins}" "$/plugins" + COMMENT "Deploy Qt plugins next to vv (from vcpkg installed tree)" + ) + else() + set(_vv_windeployqt_hints "") + if(DEFINED Qt6_DIR) + list(APPEND _vv_windeployqt_hints "${Qt6_DIR}/../../../tools/Qt6/bin") + list(APPEND _vv_windeployqt_hints "${Qt6_DIR}/../../../bin") + endif() + foreach(_p IN LISTS CMAKE_PREFIX_PATH) + list(APPEND _vv_windeployqt_hints "${_p}/tools/Qt6/bin" "${_p}/bin") + endforeach() + find_program( + VV_WINDEPLOYQT_EXECUTABLE + NAMES windeployqt windeployqt.exe + HINTS ${_vv_windeployqt_hints} + DOC "Qt deployment tool (ships with Qt)" + ) + if(VV_WINDEPLOYQT_EXECUTABLE) + add_custom_command( + TARGET vv + POST_BUILD + COMMAND "${VV_WINDEPLOYQT_EXECUTABLE}" "$" --no-compiler-runtime + COMMENT "Qt windeployqt: deploy Qt DLLs and plugins beside vv" + ) + else() + message( + WARNING + "windeployqt not found; Windows runs may fail to load Qt without manual deployment." + ) + endif() + endif() +endif() + add_custom_target(run COMMAND $ DEPENDS vv @@ -185,3 +285,41 @@ if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) endif() install(TARGETS vv RUNTIME DESTINATION bin) + +# ── macOS: bundle + Quick Look generator ───────────────────────────── +if(APPLE) + set_target_properties(vv PROPERTIES + MACOSX_BUNDLE TRUE + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/macos/Info.plist.in" + ) + + # QLGenerator: ObjC plugin that calls `vv --thumbnail` and serves the PNG to Quick Look. + add_library(vv_ql MODULE + "${CMAKE_CURRENT_SOURCE_DIR}/macos/qlgenerator/GeneratePreviewForURL.m" + ) + set_target_properties(vv_ql PROPERTIES + BUNDLE TRUE + BUNDLE_EXTENSION "qlgenerator" + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/macos/qlgenerator/Info.plist" + OUTPUT_NAME "vv_ql" + AUTOMOC OFF + ) + target_compile_options(vv_ql PRIVATE -fobjc-arc -Wno-deprecated-declarations) + target_link_libraries(vv_ql PRIVATE + "-framework CoreFoundation" + "-framework QuickLook" + "-framework Foundation" + "-framework CoreGraphics" + ) + + # Copy the .qlgenerator bundle into vv.app after both targets are built. + add_dependencies(vv vv_ql) + add_custom_command(TARGET vv POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory + "$/Contents/Library/QuickLook" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "$" + "$/Contents/Library/QuickLook/vv.qlgenerator" + COMMENT "Embedding vv.qlgenerator into app bundle" + ) +endif() diff --git a/CMakePresets.json b/CMakePresets.json index 2c22426..4c5e843 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -7,7 +7,10 @@ "generator": "Ninja", "cacheVariables": { "CMAKE_C_COMPILER": "cl.exe", - "CMAKE_CXX_COMPILER": "cl.exe" + "CMAKE_CXX_COMPILER": "cl.exe", + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON", + "CMAKE_TOOLCHAIN_FILE": "$env{USERPROFILE}/vcpkg/scripts/buildsystems/vcpkg.cmake", + "VCPKG_TARGET_TRIPLET": "x64-windows" }, "condition": { "type": "equals", @@ -46,7 +49,8 @@ "binaryDir": "${sourceDir}/build/debug", "installDir": "${sourceDir}/build/debug/install", "cacheVariables": { - "CMAKE_BUILD_TYPE": "Debug" + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" }, "condition": { "type": "equals", @@ -66,7 +70,8 @@ "binaryDir": "${sourceDir}/build", "installDir": "${sourceDir}/build/install", "cacheVariables": { - "CMAKE_BUILD_TYPE": "Release" + "CMAKE_BUILD_TYPE": "Release", + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" }, "condition": { "type": "equals", @@ -86,7 +91,8 @@ "binaryDir": "${sourceDir}/build/debug", "installDir": "${sourceDir}/build/install/debug", "cacheVariables": { - "CMAKE_BUILD_TYPE": "Debug" + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" }, "condition": { "type": "equals", @@ -106,7 +112,8 @@ "binaryDir": "${sourceDir}/build/", "installDir": "${sourceDir}/build/install/", "cacheVariables": { - "CMAKE_BUILD_TYPE": "Release" + "CMAKE_BUILD_TYPE": "Release", + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" }, "condition": { "type": "equals", diff --git a/README.md b/README.md index 3b2accc..90cd0e8 100644 --- a/README.md +++ b/README.md @@ -13,32 +13,178 @@ sudo apt install -y curl zip unzip tar pkg-config cmake ninja-build libvtk9-dev `fmt` and `cxxopts` are fetched automatically by CMake when not installed. `VTK` must be available on the system so `find_package(VTK)` can resolve it. -## Build (with CMake presets) +## Build and run + +The quickest path on macOS/Linux is the helper script, which configures, builds, +and (optionally) runs static analysis and installs: + +```sh +./build.sh # Release build into ./build +./build.sh -t Debug # Debug build +./build.sh --analyze # build, then cppcheck + clang-tidy +./build.sh --install # build, then install the app/binary +./build.sh --help # all options +``` + +`build.sh` always emits `build/compile_commands.json` for tooling. + +### Using CMake presets directly Choose the appropriate preset for your platform and configuration (e.g., `macos-debug`, `macos-release`, `linux-debug`, `linux-release`, `win-x64-debug`, `win-x64-release`). ```sh PRESET=your-preset cmake --preset=$PRESET -cmake --build --preset=$PRESET -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -cp build/compile_commands.json . # Needed for C/C++ autocompletion and symbol search in VSCode +cmake --build --preset=$PRESET +``` + +`CMAKE_EXPORT_COMPILE_COMMANDS=ON` is set in the presets. Pass CMake `-D...` options to the configure step (`cmake --preset=... -DNAME=VALUE`), not the build step (`cmake --build ...`). + +The project builds a single Qt-based executable: `vv`. On macOS, CMake packages it as an app bundle, so the binary is inside `vv.app`. + +Run on macOS: + +```sh +./build/vv.app/Contents/MacOS/vv /path/to/mesh.json +``` + +Run on Linux or Windows: + +```sh +./build/vv /path/to/mesh.json ``` -The project builds a single Qt-based executable: `vv`. Required UI dependencies are Qt6 Widgets and `VTK::GUISupportQt`. -To analyze your code for common issues and style problems, use: +### Scalar fields + +Press **Space** to cycle through the available scalar fields (and back to plain +geometry). Both **point-data** and **cell-data** scalars are supported; cell +fields are labelled `… (cells)` in the colorbar title. Categorical integer +fields (2–20 distinct values) get a discrete tab10/tab20 colormap; continuous +fields get a draggable clip range. Use `-e/--explode` to show every field at +once in a synchronized facet grid. + +## Quality checks + +Strict warnings are enabled by default and treated as errors. For local checks, configure and build the preset you use: ```sh -cppcheck --check-level=exhaustive --enable=warning,style,performance,portability,unusedFunction --project=build/compile_commands.json +cmake --preset=linux-debug +cmake --build --preset=linux-debug ``` -## Install +Run formatter through CMake when `clang-format` is installed: + +```sh +cmake --build --preset=linux-debug --target format +git diff --check +``` + +### Static analysis + +`build.sh` wraps cppcheck and clang-tidy with the right flags and a curated +suppressions list (`.cppcheck-suppressions`) that filters out Qt `moc`/autogen +false positives: + +```sh +./build.sh --cppcheck # cppcheck only +./build.sh --clang-tidy # clang-tidy only +./build.sh --analyze # both +./build.sh --format-check # fail if clang-format would change anything +``` + +To invoke cppcheck by hand against a build directory: + +```sh +cppcheck --check-level=exhaustive \ + --enable=warning,style,performance,portability \ + --suppressions-list=.cppcheck-suppressions \ + --inline-suppr -i build --library=qt \ + --project=build/compile_commands.json --error-exitcode=1 +``` + +### For Windows + +One-time installs (elevate if `winget` asks): + +```powershell +winget install Microsoft.VisualStudio.2022.BuildTools --override "--wait --passive --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended" +winget install -e --id Kitware.CMake +winget install -e --id Ninja-build.Ninja +winget install -e --id Git.Git +``` + +#### vcpkg (clone + `PATH`) + +```powershell +git clone https://github.com/microsoft/vcpkg $env:USERPROFILE\vcpkg +& "$env:USERPROFILE\vcpkg\bootstrap-vcpkg.bat" +``` + +Add `%USERPROFILE%\vcpkg` to your **user** `Path` (Settings → Environment Variables), or for the current session only: + +```powershell +$vpkg = "$env:USERPROFILE\vcpkg" +$env:Path += ";$vpkg" +``` + +If you change **user** `Path`, **open a new terminal** before `vcpkg` is recognized (existing windows still have the old `PATH`). To refresh `PATH` in the current PowerShell without restarting: + +```powershell +$env:Path = [Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [Environment]::GetEnvironmentVariable("Path", "User") +``` + +#### MSVC `cl.exe` (not the same as vcpkg) + +`cl` is not installed as a single global binary. The C++ toolchain sets `PATH`, `INCLUDE`, `LIB`, and related variables via **Visual Studio’s developer environment**. Putting only the folder that contains `cl.exe` on `PATH` is not enough for real builds. -No need to build (already done in install script). +Use one of these: + +- **Easiest:** Start **Developer PowerShell for VS 2022** (or **x64 Native Tools Command Prompt**) from the Start menu, then run CMake from that window. +- **Same PowerShell window:** load the environment for this session (adjust if you use Build Tools and `vswhere` finds nothing—install the **Desktop development with C++** / **VC Tools** workload): + +```powershell +$vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath +Import-Module "$vs\Common7\Tools\Microsoft.VisualStudio.DevShell.dll" +Enter-VsDevShell -VsInstallPath $vs -SkipAutomaticLocation -DevCmdArguments "-arch=x64" +``` + +After that, `cl`, `cmake`, `ninja`, and `cmake --preset win-x64-release` see the compiler. + +Optionally add the `Enter-VsDevShell` block to your **PowerShell profile** if you want `cl` in every new shell; it increases profile load time. Do **not** add `bootstrap-vcpkg.bat` to your profile (run bootstrap only when installing or updating vcpkg). + +#### Sanity checks + +```powershell +cl +cmake --version +ninja --version +vcpkg version +``` + +On **Windows**, if you use **vcpkg**, CMake copies **`Qt6/plugins`** from the installed triplet into **`plugins/`** next to **`vv.exe`** after each link (vcpkg’s layout is not compatible with **`windeployqt`**). Without vcpkg, CMake runs **`windeployqt`** instead. The vcpkg toolchain also copies dependent DLLs beside the executable. Disable with **`VV_QT_WINDOWS_DEPLOY=OFF`** if needed. You should not need **`QT_PLUGIN_PATH`** for normal development. + +## Install ```sh -./install.sh +./build.sh --install ``` -Make sure `~/.local/bin` is in your PATH. +On macOS this copies `vv.app` to `~/Applications`, symlinks the CLI into +`~/.local/bin`, and registers the Quick Look generator. On Linux it copies the +`vv` binary to `~/.local/bin`. Override locations with the `INSTALL_DIR` and +`APP_INSTALL_DIR` environment variables. Make sure `~/.local/bin` is in your PATH. + +## Releases / packaging + +Tagged pushes (`v*`) trigger `.github/workflows/release.yml`, which builds +**self-contained** bundles with Qt and VTK included, so end users need nothing +preinstalled: + +- **Linux** — `.AppImage` (via `linuxdeploy` + the Qt plugin) +- **macOS** — zipped `vv.app` (via `macdeployqt`) +- **Windows** — `.zip` with `vv.exe`, Qt plugins, and the linked MinGW/VTK DLLs + +Bare-binary archives are also published for users who already have the runtime +from a package manager. diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..fbc4429 --- /dev/null +++ b/build.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# +# build.sh — configure, build, analyze, and install vv. +# +# Usage: +# ./build.sh [options] +# +# Options: +# -t, --type Build type (default: Release) +# -d, --build-dir Build directory (default: build) +# -j, --jobs Parallel build jobs (default: CPU count) +# -c, --clean Remove the build directory before configuring +# --no-build Configure only; skip the compile step +# --cppcheck Run cppcheck static analysis after building +# --clang-tidy Run clang-tidy over the compilation database +# --analyze Shorthand for --cppcheck --clang-tidy +# --format-check Verify clang-format cleanliness (no changes) +# --install Install the app/binary after a successful build +# -h, --help Show this help and exit +# +# Static analysis relies on build/compile_commands.json, which this script +# always generates (CMAKE_EXPORT_COMPILE_COMMANDS=ON). +set -euo pipefail + +# ── pretty output ────────────────────────────────────────────────────── +if [ -t 1 ]; then + CYAN='\033[0;36m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; GREEN='\033[0;32m'; NC='\033[0m' +else + CYAN=''; YELLOW=''; RED=''; GREEN=''; NC='' +fi +run() { printf "${CYAN}>>${YELLOW} %s${NC}\n" "$*"; eval "$@"; } +info() { printf "${GREEN}%s${NC}\n" "$*"; } +die() { printf "${RED}error:${NC} %s\n" "$*" >&2; exit 1; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# ── defaults ─────────────────────────────────────────────────────────── +BUILD_TYPE=Release +BUILD_DIR=build +JOBS="" +CLEAN=0 +DO_BUILD=1 +RUN_CPPCHECK=0 +RUN_CLANG_TIDY=0 +RUN_FORMAT_CHECK=0 +DO_INSTALL=0 + +while [ $# -gt 0 ]; do + case "$1" in + -t|--type) BUILD_TYPE="$2"; shift 2 ;; + -d|--build-dir) BUILD_DIR="$2"; shift 2 ;; + -j|--jobs) JOBS="$2"; shift 2 ;; + -c|--clean) CLEAN=1; shift ;; + --no-build) DO_BUILD=0; shift ;; + --cppcheck) RUN_CPPCHECK=1; shift ;; + --clang-tidy) RUN_CLANG_TIDY=1; shift ;; + --analyze) RUN_CPPCHECK=1; RUN_CLANG_TIDY=1; shift ;; + --format-check) RUN_FORMAT_CHECK=1; shift ;; + --install) DO_INSTALL=1; shift ;; + -h|--help) sed -n '2,22p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) die "unknown option: $1 (try --help)" ;; + esac +done + +if [ -z "$JOBS" ]; then + JOBS="$( { nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4; } )" +fi + +command -v cmake >/dev/null || die "cmake not found" + +# ── configure ────────────────────────────────────────────────────────── +if [ "$CLEAN" = 1 ] && [ -d "$BUILD_DIR" ]; then + run "rm -rf '$BUILD_DIR'" +fi + +GENERATOR="" +command -v ninja >/dev/null && GENERATOR="-G Ninja" + +run "cmake -S . -B '$BUILD_DIR' $GENERATOR \ + -DCMAKE_BUILD_TYPE='$BUILD_TYPE' \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ + -DVV_ENABLE_WARNINGS=ON \ + -DVV_WARNINGS_AS_ERRORS=ON" + +# ── format check (optional) ──────────────────────────────────────────── +if [ "$RUN_FORMAT_CHECK" = 1 ]; then + command -v clang-format >/dev/null || die "clang-format not found" + info "Checking formatting…" + run "cmake --build '$BUILD_DIR' --target format" + if ! git diff --quiet; then + die "clang-format produced changes; run 'cmake --build $BUILD_DIR --target format' and commit" + fi + info "Formatting clean." +fi + +# ── build ────────────────────────────────────────────────────────────── +if [ "$DO_BUILD" = 1 ]; then + run "cmake --build '$BUILD_DIR' --config '$BUILD_TYPE' -j '$JOBS'" +fi + +# ── cppcheck (optional) ──────────────────────────────────────────────── +if [ "$RUN_CPPCHECK" = 1 ]; then + command -v cppcheck >/dev/null || die "cppcheck not found" + [ -f "$BUILD_DIR/compile_commands.json" ] || die "missing $BUILD_DIR/compile_commands.json" + info "Running cppcheck…" + # --check-level=exhaustive needs cppcheck >= 2.11; older distros (e.g. Ubuntu + # 22.04 ships 2.7) reject the flag, so probe for it instead of assuming. + CHECK_LEVEL="" + if cppcheck --check-level=exhaustive --version >/dev/null 2>&1; then + CHECK_LEVEL="--check-level=exhaustive" + fi + run "cppcheck \ + --project='$BUILD_DIR/compile_commands.json' \ + $CHECK_LEVEL \ + --enable=warning,style,performance,portability \ + --suppressions-list='$SCRIPT_DIR/.cppcheck-suppressions' \ + --inline-suppr \ + -i '$BUILD_DIR' \ + --library=qt \ + --quiet \ + --error-exitcode=1" + info "cppcheck clean." +fi + +# ── clang-tidy (optional) ────────────────────────────────────────────── +if [ "$RUN_CLANG_TIDY" = 1 ]; then + # Prefer PATH; fall back to a Homebrew LLVM install (Apple doesn't ship clang-tidy). + CLANG_TIDY="$(command -v clang-tidy || true)" + if [ -z "$CLANG_TIDY" ] && command -v brew >/dev/null; then + _llvm="$(brew --prefix llvm 2>/dev/null || true)" + [ -x "$_llvm/bin/clang-tidy" ] && CLANG_TIDY="$_llvm/bin/clang-tidy" + fi + [ -n "$CLANG_TIDY" ] || die "clang-tidy not found (try: brew install llvm)" + [ -f "$BUILD_DIR/compile_commands.json" ] || die "missing $BUILD_DIR/compile_commands.json" + info "Running clang-tidy ($CLANG_TIDY)…" + # Only our own sources; skip generated autogen/moc translation units. + # (Portable array fill — macOS ships bash 3.2, which lacks `mapfile`.) + files=() + while IFS= read -r f; do files+=("$f"); done \ + < <(find src -name '*.cpp' -not -path '*/build/*') + # On macOS a Homebrew clang-tidy needs the SDK sysroot to find libc++/system + # headers, since the compile DB was produced by AppleClang. + tidy_args=(-p "$BUILD_DIR" --quiet) + if [ "$(uname)" = "Darwin" ] && command -v xcrun >/dev/null; then + tidy_args+=("--extra-arg=-isysroot$(xcrun --show-sdk-path)") + fi + # Invoke directly (not via the eval-based `run`) so the file list isn't split. + printf "${CYAN}>>${YELLOW} %s${NC}\n" "$CLANG_TIDY ${tidy_args[*]} " + "$CLANG_TIDY" "${tidy_args[@]}" "${files[@]}" + info "clang-tidy clean." +fi + +# ── install (optional) ───────────────────────────────────────────────── +if [ "$DO_INSTALL" = 1 ]; then + INSTALL_DIR=${INSTALL_DIR:-$HOME/.local/bin} + run "mkdir -p '$INSTALL_DIR'" + case "$(uname)" in + Darwin) + APP_INSTALL_DIR=${APP_INSTALL_DIR:-$HOME/Applications} + run "mkdir -p '$APP_INSTALL_DIR'" + run "rm -rf '$APP_INSTALL_DIR/vv.app'" + run "cp -R '$BUILD_DIR/vv.app' '$APP_INSTALL_DIR/vv.app'" + run "ln -sf '$APP_INSTALL_DIR/vv.app/Contents/MacOS/vv' '$INSTALL_DIR/vv'" + run "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f '$APP_INSTALL_DIR/vv.app'" + run "qlmanage -r" || true + info "Installed vv.app to $APP_INSTALL_DIR (CLI symlink: $INSTALL_DIR/vv)" + ;; + Linux) + run "cp '$BUILD_DIR/vv' '$INSTALL_DIR/'" + info "Installed vv to $INSTALL_DIR (ensure it is on your PATH)" + ;; + *) + die "unsupported platform for --install: $(uname)" + ;; + esac +fi + +info "Done." diff --git a/install.sh b/install.sh deleted file mode 100755 index 4ce1059..0000000 --- a/install.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/bin/sh -set -e - -run() { - CYAN='\033[0;36m' - YELLOW='\033[1;33m' - NC='\033[0m' - printf "${CYAN}>>${YELLOW} %s${NC}\n" "$*" - eval "$@" -} - -INSTALL_DIR=${INSTALL_DIR:-~/.local/bin} -run "mkdir -p $INSTALL_DIR" - -# Detect platform and set preset -if [ "$(uname)" = "Darwin" ]; then - PRESET=macos-release -elif [ "$(uname)" = "Linux" ]; then - PRESET=linux-release -else - echo "Unsupported platform: $(uname)" - exit 1 -fi - -run "cmake --preset=$PRESET" -run "cmake --build --preset=$PRESET" -run "cp ./build/vv $INSTALL_DIR" - -echo -echo "Installed vv to $INSTALL_DIR (make sure it's in your PATH)" diff --git a/macos/Info.plist.in b/macos/Info.plist.in new file mode 100644 index 0000000..e2a5164 --- /dev/null +++ b/macos/Info.plist.in @@ -0,0 +1,95 @@ + + + + + CFBundleName + vv + CFBundleDisplayName + VV Mesh Viewer + CFBundleIdentifier + com.vv.meshviewer + CFBundleVersion + @PROJECT_VERSION@ + CFBundleShortVersionString + @PROJECT_VERSION@ + CFBundleExecutable + vv + CFBundlePackageType + APPL + CFBundleSignature + ???? + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + + UTExportedTypeDeclarations + + + UTTypeIdentifier + org.vtk.vtk-legacy + UTTypeDescription + VTK Legacy Mesh + UTTypeConformsTo + public.data + UTTypeTagSpecification + + public.filename-extension + vtk + + + + UTTypeIdentifier + org.vtk.vtp + UTTypeDescription + VTK XML PolyData + UTTypeConformsTo + public.xml + UTTypeTagSpecification + + public.filename-extension + vtp + + + + UTTypeIdentifier + org.vtk.vtu + UTTypeDescription + VTK XML Unstructured Grid + UTTypeConformsTo + public.xml + UTTypeTagSpecification + + public.filename-extension + vtu + + + + + + CFBundleDocumentTypes + + + CFBundleTypeName + VTK Mesh File + CFBundleTypeRole + Viewer + LSHandlerRank + Owner + LSItemContentTypes + + org.vtk.vtk-legacy + org.vtk.vtp + org.vtk.vtu + + CFBundleTypeExtensions + + vtk + vtp + vtu + + + + + diff --git a/macos/qlgenerator/GeneratePreviewForURL.m b/macos/qlgenerator/GeneratePreviewForURL.m new file mode 100644 index 0000000..7912ccd --- /dev/null +++ b/macos/qlgenerator/GeneratePreviewForURL.m @@ -0,0 +1,102 @@ +#import +#import +#import +#import +#import + +// kUTTypePNG removed in macOS 12 SDK — use the raw UTI string directly. +#define VV_UTI_PNG CFSTR("public.png") + +// Locate the vv executable bundled alongside this qlgenerator. +// At runtime this dylib lives at: +// vv.app/Contents/Library/QuickLook/vv.qlgenerator/Contents/MacOS/vv_ql +// So vv is at: +// vv.app/Contents/MacOS/vv +static NSString *findVVExecutable(void) { + Dl_info info; + if (dladdr((void *)findVVExecutable, &info) == 0 || !info.dli_fname) { + return nil; + } + NSString *dylibPath = [NSString stringWithUTF8String:info.dli_fname]; + // Strip: MacOS/vv_ql → Contents → vv.qlgenerator → QuickLook → Library → Contents + NSString *macosDir = [dylibPath stringByDeletingLastPathComponent]; + NSString *qlgenContents = [macosDir stringByDeletingLastPathComponent]; + NSString *qlgenBundle = [qlgenContents stringByDeletingLastPathComponent]; + NSString *quicklookDir = [qlgenBundle stringByDeletingLastPathComponent]; + NSString *libraryDir = [quicklookDir stringByDeletingLastPathComponent]; + NSString *appContents = [libraryDir stringByDeletingLastPathComponent]; + NSString *vvPath = [appContents stringByAppendingPathComponent:@"MacOS/vv"]; + return [[NSFileManager defaultManager] fileExistsAtPath:vvPath] ? vvPath : nil; +} + +static NSString *renderToPNG(CFURLRef url) { + NSString *vvPath = findVVExecutable(); + if (!vvPath) return nil; + + NSString *filePath = [(__bridge NSURL *)url path]; + NSString *tmpPng = [NSTemporaryDirectory() stringByAppendingPathComponent: + [NSString stringWithFormat:@"vv_ql_%@.png", [[NSUUID UUID] UUIDString]]]; + + NSTask *task = [[NSTask alloc] init]; + task.launchPath = vvPath; + task.arguments = @[@"--thumbnail", filePath, tmpPng]; + // Suppress stdout/stderr from the subprocess + task.standardOutput = [NSFileHandle fileHandleWithNullDevice]; + task.standardError = [NSFileHandle fileHandleWithNullDevice]; + [task launch]; + [task waitUntilExit]; + + return (task.terminationStatus == 0) ? tmpPng : nil; +} + +OSStatus GeneratePreviewForURL(void *thisInterface, + QLPreviewRequestRef preview, + CFURLRef url, + CFStringRef contentTypeUTI, + CFDictionaryRef options) { + @autoreleasepool { + NSString *pngPath = renderToPNG(url); + if (!pngPath) return noErr; + + NSData *pngData = [NSData dataWithContentsOfFile:pngPath]; + [[NSFileManager defaultManager] removeItemAtPath:pngPath error:nil]; + if (!pngData) return noErr; + + NSDictionary *props = @{(__bridge NSString *)kQLPreviewPropertyMIMETypeKey: @"image/png"}; + QLPreviewRequestSetDataRepresentation(preview, + (__bridge CFDataRef)pngData, + VV_UTI_PNG, + (__bridge CFDictionaryRef)props); + } + return noErr; +} + +void CancelPreviewGeneration(void *thisInterface, QLPreviewRequestRef preview) {} + +OSStatus GenerateThumbnailForURL(void *thisInterface, + QLThumbnailRequestRef thumbnail, + CFURLRef url, + CFStringRef contentTypeUTI, + CFDictionaryRef options, + CGFloat maxSize) { + @autoreleasepool { + NSString *pngPath = renderToPNG(url); + if (!pngPath) return noErr; + + NSData *pngData = [NSData dataWithContentsOfFile:pngPath]; + [[NSFileManager defaultManager] removeItemAtPath:pngPath error:nil]; + if (!pngData) return noErr; + + CGDataProviderRef provider = CGDataProviderCreateWithCFData((__bridge CFDataRef)pngData); + CGImageRef image = CGImageCreateWithPNGDataProvider(provider, NULL, false, + kCGRenderingIntentDefault); + if (image) { + QLThumbnailRequestSetImage(thumbnail, image, NULL); + CGImageRelease(image); + } + CGDataProviderRelease(provider); + } + return noErr; +} + +void CancelThumbnailGeneration(void *thisInterface, QLThumbnailRequestRef thumbnail) {} diff --git a/macos/qlgenerator/Info.plist b/macos/qlgenerator/Info.plist new file mode 100644 index 0000000..37d6443 --- /dev/null +++ b/macos/qlgenerator/Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleIdentifier + com.vv.meshviewer.qlgenerator + CFBundleName + vv Quick Look + CFBundleVersion + 1.0 + CFBundleShortVersionString + 1.0 + CFBundleExecutable + vv_ql + CFBundlePackageType + XPC! + QLSupportsConcurrentRequests + + QLNeedsToBeRunInMainThread + + QLPreviewHeight + 800 + QLPreviewWidth + 1000 + QLThumbnailMinimumSize + 17 + LSItemContentTypes + + org.vtk.vtk-legacy + org.vtk.vtp + org.vtk.vtu + + + diff --git a/src/ColorBarWidget.cpp b/src/ColorBarWidget.cpp index d558161..3a46222 100644 --- a/src/ColorBarWidget.cpp +++ b/src/ColorBarWidget.cpp @@ -86,8 +86,31 @@ void ColorBarWidget::setTitle(const QString& title) { update(); } +void ColorBarWidget::setCategorical(const std::vector>& entries) { + categorical_ = true; + catEntries_ = entries; + update(); +} + +void ColorBarWidget::clearCategorical() { + categorical_ = false; + catEntries_.clear(); + update(); +} + QSize ColorBarWidget::sizeHint() const { - return {90, 340}; + int width = 90; + if (!title_.isEmpty()) { + QFont bold = font(); + bold.setBold(true); + width = std::max(width, QFontMetrics(bold).horizontalAdvance(title_) + 8); + } + for (const auto& entry : catEntries_) { + width = + std::max(width, fontMetrics().horizontalAdvance(entry.first) + kBarLeft + kBarWidth + 14); + } + width = std::clamp(width, 90, 180); + return {width, 340}; } QSize ColorBarWidget::minimumSizeHint() const { return {72, 180}; @@ -141,14 +164,6 @@ ColorBarWidget::Handle ColorBarWidget::hitTestHandle(const QPointF& pos, double return None; } -QColor ColorBarWidget::colorForValue(double value) const { - if (globalMax_ <= globalMin_) - return QColor(128, 128, 128); - double t = std::clamp((value - globalMin_) / (globalMax_ - globalMin_), 0.0, 1.0); - // Match VTK default rainbow: hue 0.0 → 0.8 - return QColor::fromHsvF(t * 0.8, 1.0, 1.0); -} - void ColorBarWidget::emitClipChanged() { emit clipRangeChanged(clipMin_, clipMax_); } @@ -163,7 +178,7 @@ void ColorBarWidget::showInlineEditor(Handle handle) { const int x = static_cast(bar.right() + kHandleW + 4); const int w = 58; const int h = 22; - const int top = std::clamp(static_cast(y - h / 2), 2, height() - h - 2); + const int top = std::clamp(static_cast(y - h / 2.0), 2, height() - h - 2); inlineEditor_->setGeometry(x, top, w, h); inlineEditor_->setText(QString::number(handle == Upper ? clipMax_ : clipMin_, 'f', 2)); inlineEditor_->setVisible(true); @@ -201,21 +216,72 @@ void ColorBarWidget::paintEvent(QPaintEvent*) { const QRectF bar = barRect(); const QFont base = font(); const QFontMetrics fm(base); - const QColor txtCol = palette().text().color(); + const QColor txtCol = Qt::white; + const QColor txtShadow(0, 0, 0, 220); + auto drawTextRect = [&](const QRectF& rect, int flags, const QString& text) { + p.setPen(txtShadow); + p.drawText(rect.translated(1.0, 1.0), flags, text); + p.setPen(txtCol); + p.drawText(rect, flags, text); + }; + auto drawTextPoint = [&](const QPointF& point, const QString& text) { + p.setPen(txtShadow); + p.drawText(point + QPointF(1.0, 1.0), text); + p.setPen(txtCol); + p.drawText(point, text); + }; // ── title ───────────────────────────────────────────────────── if (!title_.isEmpty()) { QFont bold = base; bold.setBold(true); p.setFont(bold); - p.setPen(txtCol); const QRectF titleRect(2.0, kTitleTopPad, width() - 4.0, fm.height()); const QString elided = QFontMetrics(bold).elidedText(title_, Qt::ElideRight, static_cast(titleRect.width())); - p.drawText(titleRect, Qt::AlignHCenter, elided); + drawTextRect(titleRect, Qt::AlignHCenter, elided); p.setFont(base); } + // ── categorical (indexed) mode ───────────────────────────────── + if (categorical_ && !catEntries_.empty()) { + const int n = static_cast(catEntries_.size()); + const double swatchH = bar.height() / n; + + QFont small = base; + small.setPointSizeF(base.pointSizeF() * 0.82); + p.setFont(small); + QFontMetrics sfm(small); + + for (int i = 0; i < n; ++i) { + // catEntries_ is stored top→bottom (highest value first) + const auto& [label, color] = catEntries_[static_cast(i)]; + const double y = bar.top() + i * swatchH; + const QRectF swatch(bar.left(), y, kBarWidth, swatchH); + p.fillRect(swatch, color); + p.setPen(QPen(QColor(0, 0, 0, 180), 0.5)); + p.drawRect(swatch); + + // value label to the right + const QRectF labelRect(bar.right() + 4, y, width() - bar.right() - 6, swatchH); + drawTextRect(labelRect, + Qt::AlignLeft | Qt::AlignVCenter, + sfm.elidedText(label, Qt::ElideRight, static_cast(labelRect.width()))); + } + + // min / max flanking labels (outside bar, same as continuous mode) + p.setFont(small); + drawTextRect( + QRectF( + bar.left(), bar.top() - sfm.height() - kLabelGap, width() - bar.left(), sfm.height()), + Qt::AlignLeft | Qt::AlignBottom, + QString::number(globalMax_, 'g', 4)); + drawTextRect(QRectF(bar.left(), bar.bottom() + kLabelGap, width() - bar.left(), sfm.height()), + Qt::AlignLeft | Qt::AlignTop, + QString::number(globalMin_, 'g', 4)); + return; // skip gradient, handles, ticks + } + // ── gradient bar ────────────────────────────────────────────── { QLinearGradient grad(bar.topLeft(), bar.bottomLeft()); @@ -233,13 +299,14 @@ void ColorBarWidget::paintEvent(QPaintEvent*) { mappedValue = clipMin_; const double t = (mappedValue - clipMin_) / clipSpan; - grad.setColorAt(pos, QColor::fromHsvF(std::clamp(t, 0.0, 1.0) * 0.8, 1.0, 1.0)); + grad.setColorAt( + pos, QColor::fromHsvF(static_cast(std::clamp(t, 0.0, 1.0) * 0.8), 1.0f, 1.0f)); } p.fillRect(bar, grad); } // ── bar outline ─────────────────────────────────────────────── - p.setPen(QPen(palette().mid().color(), 1)); + p.setPen(QPen(QColor(0, 0, 0, 220), 1)); p.setBrush(Qt::NoBrush); p.drawRect(bar); @@ -249,7 +316,7 @@ void ColorBarWidget::paintEvent(QPaintEvent*) { if (range > 0) { double step = niceTickStep(range, 6); double first = std::ceil(clipMin_ / step) * step; - p.setPen(QPen(txtCol, 0.5)); + p.setPen(QPen(txtCol, 0.75)); for (double v = first; v <= clipMax_ + step * 0.001; v += step) { double y = valueToY(v); if (y < bar.top() + 1 || y > bar.bottom() - 1) @@ -264,19 +331,18 @@ void ColorBarWidget::paintEvent(QPaintEvent*) { QFont small = base; small.setPointSizeF(base.pointSizeF() * 0.85); p.setFont(small); - p.setPen(txtCol); QFontMetrics sfm(small); // max label just above bar - p.drawText( + drawTextRect( QRectF( bar.left(), bar.top() - sfm.height() - kLabelGap, width() - bar.left(), sfm.height()), Qt::AlignLeft | Qt::AlignBottom, QString::number(globalMax_, 'g', 4)); // min label just below bar - p.drawText(QRectF(bar.left(), bar.bottom() + kLabelGap, width() - bar.left(), sfm.height()), - Qt::AlignLeft | Qt::AlignTop, - QString::number(globalMin_, 'g', 4)); + drawTextRect(QRectF(bar.left(), bar.bottom() + kLabelGap, width() - bar.left(), sfm.height()), + Qt::AlignLeft | Qt::AlignTop, + QString::number(globalMin_, 'g', 4)); p.setFont(base); } @@ -302,10 +368,9 @@ void ColorBarWidget::paintEvent(QPaintEvent*) { hf.setBold(on); hf.setPointSizeF(base.pointSizeF() * 0.9); p.setFont(hf); - p.setPen(txtCol); QFontMetrics hfm(hf); - p.drawText(QPointF(rx + kHandleW + 3, y + hfm.ascent() / 2.0 - 1), - QString::number(value, 'f', 2)); + drawTextPoint(QPointF(rx + kHandleW + 3, y + hfm.ascent() / 2.0 - 1), + QString::number(value, 'f', 2)); p.setFont(base); }; @@ -315,6 +380,10 @@ void ColorBarWidget::paintEvent(QPaintEvent*) { // ── mouse interaction ─────────────────────────────────────────────── void ColorBarWidget::mousePressEvent(QMouseEvent* ev) { + if (categorical_) { + QWidget::mousePressEvent(ev); + return; + } if (inlineEditor_ && inlineEditor_->isVisible()) { commitInlineEditor(); } @@ -332,6 +401,10 @@ void ColorBarWidget::mousePressEvent(QMouseEvent* ev) { } void ColorBarWidget::mouseMoveEvent(QMouseEvent* ev) { + if (categorical_) { + QWidget::mouseMoveEvent(ev); + return; + } if (dragHandle_ != None) { double v = yToValue(mouseLocalPos(ev).y()); if (dragHandle_ == Upper) @@ -368,6 +441,10 @@ void ColorBarWidget::mouseReleaseEvent(QMouseEvent* ev) { } void ColorBarWidget::mouseDoubleClickEvent(QMouseEvent* ev) { + if (categorical_) { + QWidget::mouseDoubleClickEvent(ev); + return; + } Handle h = hitTestHandle(mouseLocalPos(ev)); if (h != None) { showInlineEditor(h); diff --git a/src/FSurfMeshParser.cpp b/src/FSurfMeshParser.cpp index 682187d..7d70913 100644 --- a/src/FSurfMeshParser.cpp +++ b/src/FSurfMeshParser.cpp @@ -38,30 +38,47 @@ std::vector> FSurfMeshParser::parse(const std::strin std::vector> polys; std::ifstream f(filename, std::ios::binary); if (!f) { - std::cerr << "Could not open FreeSurfer surface file: " << filename << std::endl; + std::cerr << "Could not open FreeSurfer surface file: " << filename << '\n'; return polys; } // Magic number: 3 bytes uint8_t magic[3]; - f.read(reinterpret_cast(magic), 3); - if (!(magic[0] == 255 && magic[1] == 255 && magic[2] == 254)) { - std::cerr << "Not a FreeSurfer surface file (bad magic)" << std::endl; + if (!f.read(reinterpret_cast(magic), 3) || + !(magic[0] == 255 && magic[1] == 255 && magic[2] == 254)) { + std::cerr << "Not a FreeSurfer surface file (bad magic)" << '\n'; return polys; } std::string headerline; std::getline(f, headerline); // header line (may include 'created by...') std::getline(f, headerline); // second header line (blank) - // Counts + // Counts. Cross-check against the remaining file size so a corrupt header + // cannot trigger a huge allocation or reads of garbage data. uint32_t nv, nt; - f.read(reinterpret_cast(&nv), 4); + if (!f.read(reinterpret_cast(&nv), 4) || !f.read(reinterpret_cast(&nt), 4)) { + std::cerr << "Truncated FreeSurfer surface file: " << filename << '\n'; + return polys; + } nv = bswap32(nv); - f.read(reinterpret_cast(&nt), 4); nt = bswap32(nt); + const std::streamoff dataStart = f.tellg(); + f.seekg(0, std::ios::end); + const std::streamoff fileEnd = f.tellg(); + f.seekg(dataStart, std::ios::beg); + const uint64_t available = (fileEnd > dataStart) ? static_cast(fileEnd - dataStart) : 0; + const uint64_t needed = (static_cast(nv) + static_cast(nt)) * 12u; + if (nv == 0 || nt == 0 || needed > available) { + std::cerr << "Invalid FreeSurfer surface counts in " << filename << " (vertices=" << nv + << ", triangles=" << nt << ")" << '\n'; + return polys; + } vtkNew pts; pts->SetNumberOfPoints(nv); for (uint32_t i = 0; i < nv; ++i) { float xyz[3]; - f.read(reinterpret_cast(xyz), 12); + if (!f.read(reinterpret_cast(xyz), 12)) { + std::cerr << "Truncated FreeSurfer surface file: " << filename << '\n'; + return polys; + } for (int j = 0; j < 3; ++j) { uint32_t tmp; memcpy(&tmp, xyz + j, 4); @@ -74,10 +91,17 @@ std::vector> FSurfMeshParser::parse(const std::strin vtkNew tris; for (uint32_t i = 0; i < nt; ++i) { uint32_t tidx[3]; - f.read(reinterpret_cast(tidx), 12); + if (!f.read(reinterpret_cast(tidx), 12)) { + std::cerr << "Truncated FreeSurfer surface file: " << filename << '\n'; + return polys; + } for (int j = 0; j < 3; ++j) { tidx[j] = bswap32(tidx[j]); } + if (tidx[0] >= nv || tidx[1] >= nv || tidx[2] >= nv) { + std::cerr << "FreeSurfer surface has out-of-range triangle index in " << filename << '\n'; + return polys; + } vtkIdType triangle[3] = {static_cast(tidx[0]), static_cast(tidx[1]), static_cast(tidx[2])}; diff --git a/src/JsonMeshParser.cpp b/src/JsonMeshParser.cpp new file mode 100644 index 0000000..2220ac8 --- /dev/null +++ b/src/JsonMeshParser.cpp @@ -0,0 +1,232 @@ +#include "JsonMeshParser.h" + +#include "mesh_utils.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using json = nlohmann::json; + +bool isMeshPart(const json& value) { + return value.is_object() && value.contains("vertices") && value.contains("indices") && + value["vertices"].is_array() && value["indices"].is_array(); +} + +bool looksLikeJsonMesh(const json& value) { + if (isMeshPart(value)) { + return true; + } + if (value.is_object() && value.contains("surface")) { + const auto& surface = value["surface"]; + return surface.is_array() && !surface.empty() && + std::all_of(surface.begin(), surface.end(), isMeshPart); + } + if (!value.is_array() || value.empty()) { + return false; + } + return std::all_of(value.begin(), value.end(), isMeshPart); +} + +const json* meshPartArray(const json& root) { + if (root.is_array()) { + return &root; + } + if (root.is_object() && root.contains("surface") && root["surface"].is_array()) { + return &root["surface"]; + } + return nullptr; +} + +std::string partName(const json& part) { + if (part.contains("name") && part["name"].is_string()) { + return part["name"].get(); + } + return {}; +} + +bool addNormals(vtkPolyData* poly, const json& part, size_t pointCount) { + if (!part.contains("normals") || !part["normals"].is_array()) { + return true; + } + + const auto& normals = part["normals"]; + if (normals.size() != pointCount * 3u) { + return false; + } + + vtkNew normalArray; + normalArray->SetName("Normals"); + normalArray->SetNumberOfComponents(3); + normalArray->SetNumberOfTuples(static_cast(pointCount)); + for (size_t i = 0; i < pointCount; ++i) { + const size_t i3 = i * 3u; + const float tuple[3] = { + normals[i3].get(), normals[i3 + 1u].get(), normals[i3 + 2u].get()}; + normalArray->SetTypedTuple(static_cast(i), tuple); + } + poly->GetPointData()->SetNormals(normalArray); + poly->GetPointData()->AddArray(normalArray); + return true; +} + +bool addColor(vtkPolyData* poly, const json& part) { + if (!part.contains("color") || !part["color"].is_array()) { + return true; + } + + const auto& color = part["color"]; + if (color.size() < 3u) { + return false; + } + + vtkNew colorArray; + colorArray->SetName("vv_part_color"); + colorArray->SetNumberOfComponents(3); + colorArray->InsertNextTuple3( + color[0].get(), color[1].get(), color[2].get()); + poly->GetFieldData()->AddArray(colorArray); + return true; +} + +vtkSmartPointer parsePart(const json& part) { + try { + const auto& vertices = part["vertices"]; + const auto& indices = part["indices"]; + if (vertices.size() % 3u != 0u || indices.size() % 3u != 0u || vertices.empty() || + indices.empty()) { + return nullptr; + } + + const size_t pointCount = vertices.size() / 3u; + if (pointCount > static_cast(std::numeric_limits::max())) { + return nullptr; + } + vtkNew points; + points->SetNumberOfPoints(static_cast(pointCount)); + for (size_t i = 0; i < pointCount; ++i) { + const size_t i3 = i * 3u; + points->SetPoint(static_cast(i), + vertices[i3].get(), + vertices[i3 + 1u].get(), + vertices[i3 + 2u].get()); + } + + vtkNew triangles; + for (size_t i = 0; i < indices.size(); i += 3u) { + vtkIdType triangle[3] = {indices[i].get(), + indices[i + 1u].get(), + indices[i + 2u].get()}; + if (triangle[0] < 0 || triangle[1] < 0 || triangle[2] < 0 || + triangle[0] >= static_cast(pointCount) || + triangle[1] >= static_cast(pointCount) || + triangle[2] >= static_cast(pointCount)) { + return nullptr; + } + triangles->InsertNextCell(3, triangle); + } + + vtkNew poly; + poly->SetPoints(points); + poly->SetPolys(triangles); + + const std::string name = partName(part); + if (!name.empty()) { + vtkNew nameArray; + nameArray->SetName("vv_part_name"); + nameArray->InsertNextValue(name); + poly->GetFieldData()->AddArray(nameArray); + } + + if (!addNormals(poly, part, pointCount)) { + return nullptr; + } + if (!addColor(poly, part)) { + return nullptr; + } + + return poly; + } catch (const json::exception&) { + return nullptr; + } +} + +} // namespace + +JsonMeshParser::~JsonMeshParser() = default; + +std::vector> JsonMeshParser::parse(const std::string& filename) { + std::vector> meshes; + std::ifstream file(filename); + if (!file.is_open()) { + return meshes; + } + + json root; + try { + file >> root; + } catch (const json::exception& e) { + std::cerr << "Failed to read JSON mesh: " << filename << ": " << e.what() << '\n'; + return meshes; + } + + if (isMeshPart(root)) { + auto poly = parsePart(root); + if (poly) { + meshes.push_back(poly); + } + return meshes; + } + + const json* parts = meshPartArray(root); + if (!parts) { + return meshes; + } + + for (const auto& part : *parts) { + if (!isMeshPart(part)) { + continue; + } + auto poly = parsePart(part); + if (poly) { + meshes.push_back(poly); + } + } + return meshes; +} + +bool JsonMeshParser::canParse(const std::string& filename) { + const std::string header = readHeader(filename, 64); + const size_t first = header.find_first_not_of(" \t\r\n"); + if (first == std::string::npos || (header[first] != '[' && header[first] != '{')) { + return false; + } + + std::ifstream file(filename); + if (!file.is_open()) { + return false; + } + + try { + json root; + file >> root; + return looksLikeJsonMesh(root); + } catch (const json::exception&) { + return false; + } +} diff --git a/src/LSDynaMeshParser.cpp b/src/LSDynaMeshParser.cpp index 488c44b..eabda51 100644 --- a/src/LSDynaMeshParser.cpp +++ b/src/LSDynaMeshParser.cpp @@ -6,8 +6,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -128,16 +130,18 @@ void parseNodeLine(const std::string& line, std::vector& out) { // Parse one *ELEMENT_SOLID* block: eid, pid then 4+ node ids. Use first 4 only (tet). // Skip $# comment lines. If we stop on a * line, put it in nextLine for caller to reuse. -void parseSolidBlock(std::ifstream& f, bool /*ortho*/, std::vector& out, std::string* nextLine) { +bool parseSolidBlock(std::ifstream& f, + bool /*ortho*/, + std::vector& out, + std::string& nextLine) { std::string line1, line2; while (std::getline(f, line1)) { const std::string t1 = trim(line1); if (t1.empty() || t1[0] == '$') continue; if (t1[0] == '*') { - if (nextLine) - *nextLine = line1; - return; + nextLine = line1; + return true; } int eid = 0; int pid = 0; @@ -151,9 +155,8 @@ void parseSolidBlock(std::ifstream& f, bool /*ortho*/, std::vector& out, st if (t2.empty() || t2[0] == '$') continue; if (t2[0] == '*') { - if (nextLine) - *nextLine = line2; - return; + nextLine = line2; + return true; } Tet tet; tet.pid = pid; @@ -175,9 +178,10 @@ void parseSolidBlock(std::ifstream& f, bool /*ortho*/, std::vector& out, st break; } } + return false; } -void parsePartBlock(std::ifstream& f, std::vector& out, std::string* nextLine) { +bool parsePartBlock(std::ifstream& f, std::vector& out, std::string& nextLine) { std::string line; std::string name; bool haveName = false; @@ -187,9 +191,8 @@ void parsePartBlock(std::ifstream& f, std::vector& out, std::string* n if (t.empty() || t[0] == '$') continue; if (t[0] == '*') { - if (nextLine) - *nextLine = line; - return; + nextLine = line; + return true; } if (!haveName) { @@ -208,16 +211,30 @@ void parsePartBlock(std::ifstream& f, std::vector& out, std::string* n p.name = name; out.push_back(p); } - return; + return false; } + return false; } // Stream-parse one file; merge into nodes/elems/parts. Resolve *INCLUDE in place. +// `visited` holds canonical paths of files already parsed so include cycles +// (a.k → b.k → a.k) terminate instead of recursing forever. void parseFile(const std::string& filepath, const std::string& baseDir, std::vector& nodes, std::vector& elems, - std::vector& parts) { + std::vector& parts, + std::unordered_set& visited) { + std::error_code ec; + std::string canonical = std::filesystem::weakly_canonical(filepath, ec).string(); + if (ec || canonical.empty()) { + canonical = filepath; + } + if (!visited.insert(canonical).second) { + std::cerr << "LSDyna: skipping already-included file " << filepath << "\n"; + return; + } + std::ifstream f(filepath); if (!f) { std::cerr << "LSDyna: cannot open " << filepath << "\n"; @@ -246,7 +263,7 @@ void parseFile(const std::string& filepath, incList.push_back((t[0] == '/') ? t : baseDir + "/" + t); } for (const auto& inc : incList) - parseFile(inc, dirOf(inc), nodes, elems, parts); + parseFile(inc, dirOf(inc), nodes, elems, parts, visited); continue; } if (t == "*NODE") { @@ -262,8 +279,7 @@ void parseFile(const std::string& filepath, if (startsWith(t, "*ELEMENT_SOLID")) { bool ortho = t.find("ORTHO") != std::string::npos; std::string next; - parseSolidBlock(f, ortho, elems, &next); - if (!next.empty()) { + if (parseSolidBlock(f, ortho, elems, next)) { line = std::move(next); reuseLine = true; } @@ -271,8 +287,7 @@ void parseFile(const std::string& filepath, } if (t == "*PART") { std::string next; - parsePartBlock(f, parts, &next); - if (!next.empty()) { + if (parsePartBlock(f, parts, next)) { line = std::move(next); reuseLine = true; } @@ -299,9 +314,9 @@ std::vector> LSDynaMeshParser::parse(const std::stri elems.reserve(65536); std::vector parts; - parseFile(filename, dirOf(filename), rawNodes, elems, parts); + std::unordered_set visited; + parseFile(filename, dirOf(filename), rawNodes, elems, parts, visited); - std::cerr << "LSDyna: after parse nodes=" << rawNodes.size() << " elems=" << elems.size() << "\n"; if (rawNodes.empty() || elems.empty()) return result; @@ -322,8 +337,10 @@ std::vector> LSDynaMeshParser::parse(const std::stri std::vector pids; pids.reserve(elemsByPid.size()); - for (const auto& kv : elemsByPid) - pids.push_back(kv.first); + std::transform(elemsByPid.begin(), + elemsByPid.end(), + std::back_inserter(pids), + [](const auto& kv) { return kv.first; }); std::sort(pids.begin(), pids.end()); for (int pid : pids) { diff --git a/src/MeshLoading.cpp b/src/MeshLoading.cpp index 9e868f8..8345492 100644 --- a/src/MeshLoading.cpp +++ b/src/MeshLoading.cpp @@ -2,14 +2,20 @@ #include "CartoMeshParser.h" #include "FSurfMeshParser.h" +#include "JsonMeshParser.h" #include "LSDynaMeshParser.h" #include "MeshParser.h" +#include "TemporalSource.h" +#include "VTKHDFMeshParser.h" #include "VTKMeshParser.h" #include "XMLMeshParser.h" #include "mesh_utils.h" +#include +#include #include -#include +#include +#include #include #include @@ -35,6 +41,21 @@ std::string partNameFromMesh(vtkDataSet* mesh) { return nameArray->GetValue(0); } +bool partColorFromMesh(vtkDataSet* mesh, std::array& color) { + if (!mesh || !mesh->GetFieldData()) { + return false; + } + auto* colorArray = vtkDataArray::SafeDownCast(mesh->GetFieldData()->GetArray("vv_part_color")); + if (!colorArray || colorArray->GetNumberOfTuples() == 0 || + colorArray->GetNumberOfComponents() < 3) { + return false; + } + double tuple[3] = {0.0, 0.0, 0.0}; + colorArray->GetTuple(0, tuple); + color = {tuple[0], tuple[1], tuple[2]}; + return true; +} + std::vector filesToProcessFromArgs(const std::vector& meshfiles, bool explodeView) { if (explodeView) { @@ -46,7 +67,9 @@ std::vector filesToProcessFromArgs(const std::vector& std::vector> buildParsers() { std::vector> parsers; parsers.emplace_back(std::make_unique()); + parsers.emplace_back(std::make_unique()); parsers.emplace_back(std::make_unique()); + parsers.emplace_back(std::make_unique()); parsers.emplace_back(std::make_unique()); parsers.emplace_back(std::make_unique()); parsers.emplace_back(std::make_unique()); @@ -55,9 +78,12 @@ std::vector> buildParsers() { class TempFileCleanup { public: - ~TempFileCleanup() { + // std::filesystem::remove(path, error_code) is noexcept, so this never throws; + // clang-tidy can't model that, hence the suppression. + ~TempFileCleanup() { // NOLINT(bugprone-exception-escape) for (const std::string& tmpFile : tmpFiles_) { - unlink(tmpFile.c_str()); + std::error_code ec; + std::filesystem::remove(tmpFile, ec); } } @@ -91,7 +117,8 @@ MeshLoadResult loadMeshes(const std::vector& meshfiles, bool explod realFilename = tmpFile; tmpCleanup.add(tmpFile); } else { - if (access(filename.c_str(), F_OK) != 0) { + std::error_code ec; + if (!std::filesystem::exists(filename, ec)) { result.ok = false; result.exitCode = 1; result.error = "Error: File does not exist: " + filename; @@ -115,6 +142,14 @@ MeshLoadResult loadMeshes(const std::vector& meshfiles, bool explod } std::vector> parsedMeshes = selected->parse(realFilename); + + // Capture temporal (playable) info if this file produced it. + if (const auto* hdfParser = dynamic_cast(selected)) { + if (auto temporal = hdfParser->temporal(); temporal && temporal->playable()) { + result.temporal = temporal; + } + } + if (parsedMeshes.empty()) { result.ok = false; result.exitCode = 3; @@ -126,7 +161,7 @@ MeshLoadResult loadMeshes(const std::vector& meshfiles, bool explod group.name = basenameOf(filename); for (size_t partIndex = 0; partIndex < parsedMeshes.size(); ++partIndex) { - auto& mesh = parsedMeshes[partIndex]; + const auto& mesh = parsedMeshes[partIndex]; const size_t globalIndex = result.meshes.meshes.size(); result.meshes.meshes.push_back(mesh); result.meshes.names.push_back(filename); @@ -138,6 +173,11 @@ MeshLoadResult loadMeshes(const std::vector& meshfiles, bool explod } else { result.meshes.partNames.push_back("Part " + std::to_string(partIndex + 1)); } + + std::array parsedColor = {0.0, 0.0, 0.0}; + const bool hasColor = partColorFromMesh(mesh, parsedColor); + result.meshes.partColors.push_back(parsedColor); + result.meshes.partHasColors.push_back(hasColor); group.partIndices.push_back(globalIndex); } diff --git a/src/MeshRenderer.cpp b/src/MeshRenderer.cpp index de35309..f791d5f 100644 --- a/src/MeshRenderer.cpp +++ b/src/MeshRenderer.cpp @@ -4,9 +4,13 @@ #include "mesh_utils.h" #include -#include +#include #include +#include +#include #include +#include +#include #include #include #include @@ -20,6 +24,19 @@ const char* kVVWindowTitle = "VV mesh viewer"; +namespace { + +std::vector rawMeshPointers(const std::vector>& meshes) { + std::vector result; + result.reserve(meshes.size()); + std::transform(meshes.begin(), meshes.end(), std::back_inserter(result), [](const auto& mesh) { + return mesh.GetPointer(); + }); + return result; +} + +} // namespace + MeshRenderer::~MeshRenderer() = default; MeshRenderer::MeshRenderer() {} @@ -81,19 +98,9 @@ void MeshRenderer::setup(const std::vector>& meshes, auto defaultStyle = vtkSmartPointer::New(); interactor->SetInteractorStyle(defaultStyle); - std::set scalarNameSet; - for (const auto& mesh : meshes) { - vtkPointData* pd = mesh->GetPointData(); - for (int i = 0; i < pd->GetNumberOfArrays(); ++i) - if (pd->GetArray(i) && pd->GetArray(i)->GetName()) - scalarNameSet.insert(pd->GetArray(i)->GetName()); - } - - availableScalars.assign(scalarNameSet.begin(), scalarNameSet.end()); + // Scalar selection is driven by the owning viewer (point and cell fields + // alike); start with geometry-only shading. clearActiveScalar(); - if (!availableScalars.empty()) { - setActiveScalar(availableScalars.front()); - } } void MeshRenderer::start() { @@ -103,31 +110,33 @@ void MeshRenderer::start() { } } -#include -#include -#include -#include -#include - void MeshRenderer::setupFacetGrid(const std::vector>& meshes, const std::vector& names, const std::vector>& colorsHex) { + (void)names; if (meshes.empty()) return; - // Collect all (mesh_index, scalar_name) pairs + // Collect all (mesh_index, scalar_name, association) tuples — one facet per + // scalar, point and cell fields alike. struct MeshScalarPair { size_t meshIndex; std::string scalarName; - std::string meshName; + FieldAssociation association; }; std::vector pairs; for (size_t j = 0; j < meshes.size(); ++j) { - auto* pd = meshes[j]->GetPointData(); - for (int i = 0; i < pd->GetNumberOfArrays(); ++i) { - if (auto* a = pd->GetArray(i)) { - if (a->GetName()) { - pairs.push_back({j, a->GetName(), names[j]}); + if (auto* pd = meshes[j]->GetPointData()) { + for (int i = 0; i < pd->GetNumberOfArrays(); ++i) { + if (auto* a = pd->GetArray(i); a && a->GetName()) { + pairs.push_back({j, a->GetName(), FieldAssociation::Point}); + } + } + } + if (auto* cd = meshes[j]->GetCellData()) { + for (int i = 0; i < cd->GetNumberOfArrays(); ++i) { + if (auto* a = cd->GetArray(i); a && a->GetName()) { + pairs.push_back({j, a->GetName(), FieldAssociation::Cell}); } } } @@ -160,7 +169,7 @@ void MeshRenderer::setupFacetGrid(const std::vector> context.window->RemoveRenderer(existing); } - const int n = static_cast(pairs.size()); + const size_t n = pairs.size(); const int cols = static_cast(std::ceil(std::sqrt(static_cast(n)))); const int rows = static_cast(std::ceil(static_cast(n) / cols)); @@ -169,8 +178,8 @@ void MeshRenderer::setupFacetGrid(const std::vector> facetPanels.clear(); context.colorsHex = colorsHex; - for (int i = 0; i < n; ++i) { - const int r = i / cols, c = i % cols; + for (size_t i = 0; i < n; ++i) { + const int r = static_cast(i) / cols, c = static_cast(i) % cols; const double xmin = double(c) / cols, xmax = double(c + 1) / cols; const double ymin = 1.0 - double(r + 1) / rows, ymax = 1.0 - double(r) / rows; @@ -180,19 +189,26 @@ void MeshRenderer::setupFacetGrid(const std::vector> const auto& pair = pairs[i]; auto& srcMesh = meshes[pair.meshIndex]; - srcMesh->GetPointData()->SetActiveScalars(pair.scalarName.c_str()); vtkNew mapper; mapper->SetInputData(srcMesh); mapper->SelectColorArray(pair.scalarName.c_str()); - mapper->SetScalarModeToUsePointData(); + if (pair.association == FieldAssociation::Cell) { + mapper->SetScalarModeToUseCellFieldData(); + } else { + mapper->SetScalarModeToUsePointFieldData(); + } mapper->SetColorModeToMapScalars(); - auto* arr = srcMesh->GetPointData()->GetArray(pair.scalarName.c_str()); + auto* arr = arrayForAssociation(srcMesh, pair.scalarName, pair.association); if (arr) { double range[2]; arr->GetRange(range); - auto lut = createDefaultLookupTable(range); + std::vector allPtrs = rawMeshPointers(meshes); + auto analysis = analyzeScalar(allPtrs, pair.scalarName, pair.association); + if (analysis.categorical && sharedCatAnalysis.categorical) + analysis = sharedCatAnalysis; + auto lut = buildLookupTable(analysis, range); mapper->SetLookupTable(lut); mapper->SetScalarRange(range); mapper->ScalarVisibilityOn(); @@ -200,6 +216,7 @@ void MeshRenderer::setupFacetGrid(const std::vector> FacetPanelState panel; panel.mapper = mapper; panel.title = pair.scalarName; + panel.analysis = std::move(analysis); panel.globalRange[0] = range[0]; panel.globalRange[1] = range[1]; panel.clipRange[0] = range[0]; @@ -257,11 +274,10 @@ void MeshRenderer::setupFacetGrid(const std::vector> ren->ResetCameraClippingRange(ub); } - static vtkSmartPointer camLinkCb; - if (!camLinkCb) - camLinkCb = vtkSmartPointer::New(); - camLinkCb->SetClientData(context.window); - camLinkCb->SetCallback([](vtkObject* caller, unsigned long, void* cd, void*) { + if (!camLinkCb_) + camLinkCb_ = vtkSmartPointer::New(); + camLinkCb_->SetClientData(context.window); + camLinkCb_->SetCallback([](vtkObject* caller, unsigned long, void* cd, void*) { auto* src = vtkCamera::SafeDownCast(caller); auto* win = static_cast(cd); if (!src || !win) @@ -278,7 +294,7 @@ void MeshRenderer::setupFacetGrid(const std::vector> rens->InitTraversal(cookie); for (vtkRenderer* ren = rens->GetNextRenderer(cookie); ren; ren = rens->GetNextRenderer(cookie)) - ren->GetActiveCamera()->AddObserver(vtkCommand::ModifiedEvent, camLinkCb); + ren->GetActiveCamera()->AddObserver(vtkCommand::ModifiedEvent, camLinkCb_); if (!interactor) { interactor = vtkSmartPointer::New(); @@ -303,28 +319,24 @@ void MeshRenderer::startFacetGrid() { } } -const std::vector& MeshRenderer::getScalarNames() const { - return availableScalars; -} - -bool MeshRenderer::setActiveScalar(const std::string& scalarName) { +bool MeshRenderer::setActiveScalar(const std::string& scalarName, FieldAssociation association) { if (scalarName.empty()) { clearActiveScalar(); return true; } - std::vector meshPtrs; - meshPtrs.reserve(sceneMeshes.size()); - for (const auto& mesh : sceneMeshes) { - meshPtrs.push_back(mesh.GetPointer()); - } + std::vector meshPtrs = rawMeshPointers(sceneMeshes); double range[2] = {0.0, 1.0}; - if (!computeScalarGlobalRange(meshPtrs, scalarName, range)) { + if (!computeScalarGlobalRange(meshPtrs, scalarName, association, range)) { return false; } activeScalarName = scalarName; + activeScalarAssociation = association; + activeScalarAnalysis = analyzeScalar(meshPtrs, scalarName, association); + if (activeScalarAnalysis.categorical && sharedCatAnalysis.categorical) + activeScalarAnalysis = sharedCatAnalysis; activeScalarGlobalRange[0] = range[0]; activeScalarGlobalRange[1] = range[1]; clipRange[0] = range[0]; @@ -332,8 +344,12 @@ bool MeshRenderer::setActiveScalar(const std::string& scalarName) { bool found = false; for (size_t index = 0; index < sceneMeshes.size() && index < mappers.size(); ++index) { - if (setMapperScalarFromPointData( - sceneMeshes[index], mappers[index], activeScalarName, clipRange)) { + if (setMapperScalar(sceneMeshes[index], + mappers[index], + activeScalarName, + activeScalarAssociation, + clipRange, + activeScalarAnalysis)) { found = true; } } @@ -351,11 +367,65 @@ bool MeshRenderer::setActiveScalar(const std::string& scalarName) { void MeshRenderer::clearActiveScalar() { activeScalarName.clear(); + activeScalarAnalysis = {}; for (size_t index = 0; index < sceneMeshes.size() && index < mappers.size(); ++index) { mappers[index]->ScalarVisibilityOff(); - if (sceneMeshes[index] && sceneMeshes[index]->GetPointData()) { + if (!sceneMeshes[index]) { + continue; + } + if (sceneMeshes[index]->GetPointData()) { sceneMeshes[index]->GetPointData()->SetActiveScalars(nullptr); } + if (sceneMeshes[index]->GetCellData()) { + sceneMeshes[index]->GetCellData()->SetActiveScalars(nullptr); + } + } + if (context.window) { + context.window->Render(); + } +} + +void MeshRenderer::refreshAfterDataChange() { + // Hot path: called once per playback frame. The mapper's lookup table, scalar + // range and color-array selection were configured when the scalar was first + // applied and stay fixed across the animation — so we only re-flag the active + // array on the freshly swapped point data and re-render. Rebuilding the LUT here + // (as the initial apply does) would re-map and re-upload every frame for nothing. + for (size_t index = 0; index < sceneMeshes.size(); ++index) { + vtkDataSet* mesh = sceneMeshes[index]; + if (!mesh) { + continue; + } + if (!activeScalarName.empty() && + arrayForAssociation(mesh, activeScalarName, activeScalarAssociation)) { + if (activeScalarAssociation == FieldAssociation::Cell) { + mesh->GetCellData()->SetActiveScalars(activeScalarName.c_str()); + } else { + mesh->GetPointData()->SetActiveScalars(activeScalarName.c_str()); + } + } + mesh->Modified(); + } + if (context.window) { + context.window->Render(); + } +} + +void MeshRenderer::setActiveScalarRange(double minValue, double maxValue) { + if (activeScalarName.empty() || minValue > maxValue) { + return; + } + activeScalarGlobalRange[0] = minValue; + activeScalarGlobalRange[1] = maxValue; + clipRange[0] = minValue; + clipRange[1] = maxValue; + for (size_t index = 0; index < sceneMeshes.size() && index < mappers.size(); ++index) { + setMapperScalar(sceneMeshes[index], + mappers[index], + activeScalarName, + activeScalarAssociation, + clipRange, + activeScalarAnalysis); } if (context.window) { context.window->Render(); @@ -371,6 +441,20 @@ bool MeshRenderer::getActiveScalarGlobalRange(double outRange[2]) const { return true; } +const ScalarAnalysis& MeshRenderer::getActiveScalarAnalysis() const { + return activeScalarAnalysis; +} + +void MeshRenderer::setSharedCatAnalysis(const ScalarAnalysis& shared) { + sharedCatAnalysis = shared; +} + +vtkLookupTable* MeshRenderer::getActiveLUT() const { + if (mappers.empty()) + return nullptr; + return vtkLookupTable::SafeDownCast(mappers.front()->GetLookupTable()); +} + void MeshRenderer::getClipRange(double outRange[2]) const { outRange[0] = clipRange[0]; outRange[1] = clipRange[1]; @@ -396,8 +480,12 @@ bool MeshRenderer::setClipRange(double minValue, double maxValue) { bool found = false; for (size_t index = 0; index < sceneMeshes.size() && index < mappers.size(); ++index) { - if (setMapperScalarFromPointData( - sceneMeshes[index], mappers[index], activeScalarName, clipRange)) { + if (setMapperScalar(sceneMeshes[index], + mappers[index], + activeScalarName, + activeScalarAssociation, + clipRange, + activeScalarAnalysis)) { found = true; } } @@ -408,10 +496,6 @@ bool MeshRenderer::setClipRange(double minValue, double maxValue) { return found; } -size_t MeshRenderer::getPartCount() const { - return context.actors.size(); -} - bool MeshRenderer::setPartVisible(size_t partIndex, bool visible) { if (partIndex >= context.actors.size() || !context.actors[partIndex]) { return false; @@ -423,13 +507,6 @@ bool MeshRenderer::setPartVisible(size_t partIndex, bool visible) { return true; } -bool MeshRenderer::isPartVisible(size_t partIndex) const { - if (partIndex >= context.actors.size() || !context.actors[partIndex]) { - return false; - } - return context.actors[partIndex]->GetVisibility() != 0; -} - size_t MeshRenderer::getFacetPanelCount() const { return facetPanels.size(); } @@ -440,6 +517,7 @@ bool MeshRenderer::getFacetPanelInfo(size_t panelIndex, FacetPanelInfo& outInfo) } const FacetPanelState& panel = facetPanels[panelIndex]; outInfo.title = panel.title; + outInfo.analysis = panel.analysis; outInfo.globalRange[0] = panel.globalRange[0]; outInfo.globalRange[1] = panel.globalRange[1]; outInfo.clipRange[0] = panel.clipRange[0]; @@ -451,6 +529,12 @@ bool MeshRenderer::getFacetPanelInfo(size_t panelIndex, FacetPanelInfo& outInfo) return true; } +vtkLookupTable* MeshRenderer::getFacetPanelLUT(size_t panelIndex) const { + if (panelIndex >= facetPanels.size()) + return nullptr; + return vtkLookupTable::SafeDownCast(facetPanels[panelIndex].mapper->GetLookupTable()); +} + bool MeshRenderer::setFacetPanelClipRange(size_t panelIndex, double minValue, double maxValue) { if (panelIndex >= facetPanels.size()) { return false; diff --git a/src/PlaybackBar.cpp b/src/PlaybackBar.cpp new file mode 100644 index 0000000..0e1dc62 --- /dev/null +++ b/src/PlaybackBar.cpp @@ -0,0 +1,218 @@ +#include "PlaybackBar.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// Icon geometry, in a 24×24 logical box. Icons are painted as vector paths so +// they stay crisp at any DPI and never depend on a system font shipping the +// Unicode media glyphs (which renders as tofu on minimal Linux/Windows images). +constexpr int kIconBox = 24; +const QColor kIconColor(232, 232, 232); // matches the bar's text color + +enum class Glyph { Play, Pause, Loop }; + +QPainterPath glyphPath(Glyph glyph) { + QPainterPath path; + switch (glyph) { + case Glyph::Play: { + path.moveTo(8.0, 6.0); + path.lineTo(18.0, 12.0); + path.lineTo(8.0, 18.0); + path.closeSubpath(); + break; + } + case Glyph::Pause: { + path.addRoundedRect(QRectF(7.5, 6.0, 3.5, 12.0), 1.2, 1.2); + path.addRoundedRect(QRectF(13.0, 6.0, 3.5, 12.0), 1.2, 1.2); + break; + } + case Glyph::Loop: { + // Circular arrow: an open ring with an arrowhead at the top opening. + QPainterPath ring; + ring.addEllipse(QRectF(5.5, 5.5, 13.0, 13.0)); + QPainterPath stroked; + { + QPainterPathStroker stroker; + stroker.setWidth(2.4); + stroker.setCapStyle(Qt::FlatCap); + stroked = stroker.createStroke(ring); + } + // Cut a gap at the top so the ring reads as a refresh arrow. + QPainterPath gap; + gap.addRect(QRectF(11.0, 2.0, 5.0, 6.0)); + path = stroked.subtracted(gap); + // Arrowhead pointing clockwise into the gap. + QPainterPath head; + head.moveTo(16.2, 3.2); + head.lineTo(16.2, 8.2); + head.lineTo(11.6, 5.7); + head.closeSubpath(); + path = path.united(head); + break; + } + } + return path; +} + +QIcon makeGlyphIcon(Glyph glyph) { + QIcon icon; + for (const int scale : {1, 2}) { + const int px = kIconBox * scale; + QPixmap pix(px, px); + pix.fill(Qt::transparent); + QPainter painter(&pix); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.scale(scale, scale); + painter.fillPath(glyphPath(glyph), kIconColor); + painter.end(); + icon.addPixmap(pix); + } + return icon; +} + +} // namespace + +PlaybackBar::PlaybackBar(int numSteps, QWidget* parent) + : QWidget(parent), numSteps_(numSteps > 0 ? numSteps : 1) { + setObjectName("playbackBar"); + setAttribute(Qt::WA_StyledBackground, true); + setFocusPolicy(Qt::NoFocus); + setStyleSheet("QWidget#playbackBar {" + " background: rgba(20,20,20,200);" + " border-radius: 8px;" + "}" + "QToolButton {" + " background: rgba(255,255,255,18);" + " color: #E8E8E8;" + " border: none;" + " border-radius: 4px;" + " padding: 2px 8px;" + " font-size: 14px;" + "}" + "QToolButton:hover { background: rgba(255,255,255,40); }" + "QToolButton:checked { background: rgba(80,150,250,160); color: white; }" + "QLabel { color: #D8D8D8; font-size: 12px; }" + "QComboBox {" + " background: rgba(255,255,255,18); color: #E8E8E8;" + " border: none; border-radius: 4px; padding: 2px 6px;" + "}" + "QComboBox QAbstractItemView { background: #202020; color: #E8E8E8; " + "selection-background-color: #5096FA; }" + "QSlider::groove:horizontal { height: 4px; background: rgba(255,255,255,50); " + "border-radius: 2px; }" + "QSlider::handle:horizontal {" + " width: 12px; margin: -5px 0; border-radius: 6px; background: #E8E8E8;" + "}" + "QSlider::sub-page:horizontal { background: #5096FA; border-radius: 2px; }"); + + auto* row = new QHBoxLayout(this); + row->setContentsMargins(10, 6, 10, 6); + row->setSpacing(8); + + playButton_ = new QToolButton(this); + playButton_->setIcon(makeGlyphIcon(Glyph::Play)); + playButton_->setIconSize(QSize(18, 18)); + playButton_->setToolTip("Play/Pause"); + playButton_->setFocusPolicy(Qt::NoFocus); + row->addWidget(playButton_); + + slider_ = new QSlider(Qt::Horizontal, this); + slider_->setMinimum(0); + slider_->setMaximum(numSteps_ - 1); + slider_->setSingleStep(1); + slider_->setPageStep(std::max(1, numSteps_ / 20)); + slider_->setFocusPolicy(Qt::NoFocus); + row->addWidget(slider_, 1); + + readout_ = new QLabel(this); + readout_->setMinimumWidth(140); + readout_->setAlignment(Qt::AlignCenter); + row->addWidget(readout_); + + speedBox_ = new QComboBox(this); + speedBox_->setFocusPolicy(Qt::NoFocus); + for (const char* label : {"0.25x", "0.5x", "1x", "2x", "4x", "8x"}) { + speedBox_->addItem(QString::fromLatin1(label)); + } + speedBox_->setCurrentIndex(2); // 1x + row->addWidget(speedBox_); + + loopButton_ = new QToolButton(this); + loopButton_->setIcon(makeGlyphIcon(Glyph::Loop)); + loopButton_->setIconSize(QSize(18, 18)); + loopButton_->setToolTip("Loop"); + loopButton_->setCheckable(true); + loopButton_->setChecked(true); + loopButton_->setFocusPolicy(Qt::NoFocus); + row->addWidget(loopButton_); + + updateReadout(0, 0.0); + + connect(playButton_, &QToolButton::clicked, this, [this]() { + setPlaying(!playing_); + emit playToggled(playing_); + }); + connect(slider_, &QSlider::valueChanged, this, [this](int value) { emit stepRequested(value); }); + connect(speedBox_, &QComboBox::currentTextChanged, this, [this]() { + emit speedChanged(speedMultiplier()); + }); + connect(loopButton_, &QToolButton::toggled, this, [this](bool on) { emit loopToggled(on); }); +} + +int PlaybackBar::currentStep() const { + return slider_->value(); +} + +double PlaybackBar::speedMultiplier() const { + switch (speedBox_->currentIndex()) { + case 0: + return 0.25; + case 1: + return 0.5; + case 2: + return 1.0; + case 3: + return 2.0; + case 4: + return 4.0; + case 5: + return 8.0; + default: + return 1.0; + } +} + +bool PlaybackBar::loopEnabled() const { + return loopButton_->isChecked(); +} + +void PlaybackBar::setStep(int step, double timeValue) { + const QSignalBlocker block(slider_); + slider_->setValue(step); + updateReadout(step, timeValue); +} + +void PlaybackBar::setPlaying(bool playing) { + playing_ = playing; + playButton_->setIcon(makeGlyphIcon(playing ? Glyph::Pause : Glyph::Play)); +} + +void PlaybackBar::updateReadout(int step, double timeValue) { + readout_->setText( + QStringLiteral("%1 / %2 t=%3").arg(step + 1).arg(numSteps_).arg(timeValue, 0, 'g', 4)); +} diff --git a/src/ScalarVizUtils.cpp b/src/ScalarVizUtils.cpp index 6a7fef5..88fa04c 100644 --- a/src/ScalarVizUtils.cpp +++ b/src/ScalarVizUtils.cpp @@ -1,24 +1,63 @@ #include "ScalarVizUtils.h" #include +#include #include +#include +#include #include +#include #include #include -#include +#include + +vtkDataArray* +arrayForAssociation(vtkDataSet* mesh, const std::string& name, FieldAssociation association) { + if (!mesh) { + return nullptr; + } + vtkDataSetAttributes* attrs = (association == FieldAssociation::Cell) + ? static_cast(mesh->GetCellData()) + : static_cast(mesh->GetPointData()); + if (!attrs) { + return nullptr; + } + return attrs->GetArray(name.c_str()); +} + +// Matplotlib tab10 palette (10 colors). +static const double kTab10[10][3] = { + {0.122, 0.467, 0.706}, + {1.000, 0.498, 0.055}, + {0.173, 0.627, 0.173}, + {0.839, 0.153, 0.157}, + {0.580, 0.404, 0.741}, + {0.549, 0.337, 0.294}, + {0.890, 0.467, 0.761}, + {0.498, 0.498, 0.498}, + {0.737, 0.741, 0.133}, + {0.090, 0.745, 0.812}, +}; + +// Matplotlib tab20 palette (20 colors). +static const double kTab20[20][3] = { + {0.122, 0.467, 0.706}, {0.682, 0.780, 0.910}, {1.000, 0.498, 0.055}, {1.000, 0.733, 0.471}, + {0.173, 0.627, 0.173}, {0.596, 0.875, 0.541}, {0.839, 0.153, 0.157}, {1.000, 0.596, 0.588}, + {0.580, 0.404, 0.741}, {0.773, 0.690, 0.835}, {0.549, 0.337, 0.294}, {0.769, 0.612, 0.580}, + {0.890, 0.467, 0.761}, {0.969, 0.714, 0.824}, {0.498, 0.498, 0.498}, {0.780, 0.780, 0.780}, + {0.737, 0.741, 0.133}, {0.859, 0.859, 0.553}, {0.090, 0.745, 0.812}, {0.620, 0.855, 0.898}, +}; bool computeScalarGlobalRange(const std::vector& meshes, const std::string& scalarName, + FieldAssociation association, double outRange[2]) { bool foundAny = false; double minValue = std::numeric_limits::max(); double maxValue = std::numeric_limits::lowest(); for (vtkDataSet* mesh : meshes) { - if (!mesh || !mesh->GetPointData()) { - continue; - } - auto* arr = mesh->GetPointData()->GetArray(scalarName.c_str()); + auto* arr = arrayForAssociation(mesh, scalarName, association); if (!arr) { continue; } @@ -39,6 +78,140 @@ bool computeScalarGlobalRange(const std::vector& meshes, return true; } +static bool isIntegerType(vtkDataArray* arr) { + const int t = arr->GetDataType(); + return t == VTK_CHAR || t == VTK_SIGNED_CHAR || t == VTK_UNSIGNED_CHAR || t == VTK_SHORT || + t == VTK_UNSIGNED_SHORT || t == VTK_INT || t == VTK_UNSIGNED_INT || t == VTK_LONG || + t == VTK_UNSIGNED_LONG || t == VTK_LONG_LONG || t == VTK_UNSIGNED_LONG_LONG; +} + +static std::set collectUniqueValues(const std::vector& meshes, + const std::string& scalarName, + FieldAssociation association, + bool roundToInt, + int maxUnique) { + std::set unique; + for (vtkDataSet* mesh : meshes) { + auto* arr = arrayForAssociation(mesh, scalarName, association); + if (!arr || arr->GetNumberOfComponents() != 1) + continue; + const vtkIdType n = arr->GetNumberOfTuples(); + for (vtkIdType i = 0; i < n; ++i) { + double v = arr->GetComponent(i, 0); + if (roundToInt) + v = std::round(v); + unique.insert(v); + if (static_cast(unique.size()) > maxUnique) + return unique; + } + } + return unique; +} + +ScalarAnalysis analyzeScalar(const std::vector& meshes, + const std::string& scalarName, + FieldAssociation association) { + ScalarAnalysis result; + if (scalarName.empty() || meshes.empty()) + return result; + + // Determine array type from first mesh that has the scalar. + bool isInt = false; + for (vtkDataSet* mesh : meshes) { + auto* arr = arrayForAssociation(mesh, scalarName, association); + if (arr && arr->GetNumberOfComponents() == 1) { + isInt = isIntegerType(arr); + break; + } + } + + // Integer arrays: collect up to 20 unique (rounded) values. + // Float arrays: collect up to 20 unique values (no rounding). + const int limit = 20; + auto unique = collectUniqueValues(meshes, scalarName, association, isInt, limit); + const int n = static_cast(unique.size()); + + if (n >= 2 && n <= limit) { + result.categorical = true; + result.uniqueValues = std::move(unique); + } + return result; +} + +ScalarAnalysis buildCommonCatAnalysis(const std::vector& meshes) { + // Collect all (name, association) fields present across any mesh. + std::set> fields; + for (vtkDataSet* mesh : meshes) { + if (!mesh) + continue; + if (auto* pd = mesh->GetPointData()) { + for (int i = 0; i < pd->GetNumberOfArrays(); ++i) { + if (const char* name = pd->GetArrayName(i)) + fields.insert({name, FieldAssociation::Point}); + } + } + if (auto* cd = mesh->GetCellData()) { + for (int i = 0; i < cd->GetNumberOfArrays(); ++i) { + if (const char* name = cd->GetArrayName(i)) + fields.insert({name, FieldAssociation::Cell}); + } + } + } + + // Union of unique values from every scalar that is itself categorical. + std::set unionValues; + for (const auto& [name, association] : fields) { + ScalarAnalysis a = analyzeScalar(meshes, name, association); + if (a.categorical) + unionValues.insert(a.uniqueValues.begin(), a.uniqueValues.end()); + } + + ScalarAnalysis result; + const int n = static_cast(unionValues.size()); + if (n >= 2 && n <= 20) { + result.categorical = true; + result.uniqueValues = std::move(unionValues); + } + return result; +} + +vtkSmartPointer createCategoricalLookupTable(const std::set& uniqueValues) { + const int n = static_cast(uniqueValues.size()); + const bool useTab20 = (n > 10); + const int paletteSize = useTab20 ? 20 : 10; + + auto lut = vtkSmartPointer::New(); + lut->SetNumberOfTableValues(n); + lut->SetIndexedLookup(1); + + int idx = 0; + for (double v : uniqueValues) { + // Index the 2D palette directly: keeps the static-array element known to be + // initialized (no pointer-to-array variable that clang-format reflows). + const int row = idx % paletteSize; + const double* c = useTab20 ? kTab20[row] : kTab10[row]; + lut->SetTableValue(idx, c[0], c[1], c[2], 1.0); + // Annotation label: show integer if the value is whole, else decimal. + char label[32]; + if (v == std::floor(v)) { + std::snprintf(label, sizeof(label), "%g", v); + } else { + std::snprintf(label, sizeof(label), "%.3g", v); + } + lut->SetAnnotation(v, label); + ++idx; + } + lut->Build(); + return lut; +} + +vtkSmartPointer buildLookupTable(const ScalarAnalysis& analysis, + const double range[2]) { + if (analysis.categorical) + return createCategoricalLookupTable(analysis.uniqueValues); + return createDefaultLookupTable(range); +} + vtkSmartPointer createDefaultLookupTable(const double range[2]) { auto lut = vtkSmartPointer::New(); lut->SetNumberOfTableValues(256); @@ -62,72 +235,36 @@ void applyLookupTableRange(vtkLookupTable* lut, const double range[2]) { lut->Build(); } -void updateScalarBar(vtkScalarBarActor* bar, - vtkRenderWindow* window, - vtkLookupTable* lut, - const std::string& title, - bool show) { - if (!bar || !window) { - return; - } - - if (!show || !lut) { - bar->SetLookupTable(nullptr); - bar->SetTitle(""); - bar->SetNumberOfLabels(0); - bar->GetLabelTextProperty()->SetFontSize(1); - bar->GetTitleTextProperty()->SetFontSize(1); - bar->SetVisibility(false); - return; - } - - bar->SetLookupTable(lut); - bar->SetTitle(title.c_str()); - bar->SetNumberOfLabels(5); - bar->SetUnconstrainedFontSize(true); - bar->SetPosition(0.86, 0.10); - bar->SetWidth(0.10); - bar->SetHeight(0.80); - bar->SetVisibility(true); - - const int* size = window->GetSize(); - const int barWidth = std::max(80, size[0] / 10); - const int barHeight = std::max(200, size[1] / 2); - bar->SetMaximumWidthInPixels(barWidth); - bar->SetMaximumHeightInPixels(barHeight); - const int fontSize = std::max(10, barHeight / 15); - bar->GetLabelTextProperty()->SetFontSize(fontSize); - bar->GetTitleTextProperty()->SetFontSize(fontSize + 2); -} - -bool setMapperScalarFromPointData(vtkDataSet* mesh, - vtkDataSetMapper* mapper, - const std::string& scalarName, - const double range[2]) { - if (!mesh || !mapper || !mesh->GetPointData()) { +bool setMapperScalar(vtkDataSet* mesh, + vtkDataSetMapper* mapper, + const std::string& scalarName, + FieldAssociation association, + const double range[2], + const ScalarAnalysis& analysis) { + if (!mesh || !mapper) { return false; } - auto* arr = mesh->GetPointData()->GetArray(scalarName.c_str()); + const auto* arr = arrayForAssociation(mesh, scalarName, association); if (!arr) { mapper->ScalarVisibilityOff(); return false; } - mesh->GetPointData()->SetActiveScalars(scalarName.c_str()); + const bool cell = (association == FieldAssociation::Cell); + if (cell) { + mesh->GetCellData()->SetActiveScalars(scalarName.c_str()); + mapper->SetScalarModeToUseCellFieldData(); + } else { + mesh->GetPointData()->SetActiveScalars(scalarName.c_str()); + mapper->SetScalarModeToUsePointFieldData(); + } mapper->SelectColorArray(scalarName.c_str()); - mapper->SetScalarModeToUsePointData(); mapper->SetColorModeToMapScalars(); mapper->ScalarVisibilityOn(); - vtkLookupTable* lut = vtkLookupTable::SafeDownCast(mapper->GetLookupTable()); - if (!lut) { - auto newLut = createDefaultLookupTable(range); - mapper->SetLookupTable(newLut); - } else { - applyLookupTableRange(lut, range); - } - + auto lut = buildLookupTable(analysis, range); + mapper->SetLookupTable(lut); mapper->SetScalarRange(range); return true; } diff --git a/src/TemporalSource.cpp b/src/TemporalSource.cpp new file mode 100644 index 0000000..9092409 --- /dev/null +++ b/src/TemporalSource.cpp @@ -0,0 +1,123 @@ +#include "TemporalSource.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +TemporalSource::TemporalSource() = default; +TemporalSource::~TemporalSource() = default; + +void TemporalSource::init(const vtkSmartPointer& reader, + std::vector timeValues) { + reader_ = reader; + timeValues_ = std::move(timeValues); + numSteps_ = static_cast(timeValues_.size()); + if (reader_) { + // Cache the static geometry/topology so successive frames only re-read the + // temporal point-data arrays (incompatible with MergeParts, which is off). + // vtkHDFReader gained UseCache in VTK 9.3; older VTK still plays back, just + // re-reading geometry each frame. +#if VTK_VERSION_NUMBER >= VTK_VERSION_CHECK(9, 3, 0) + reader_->UseCacheOn(); +#endif + } +} + +void TemporalSource::setActiveArray(const std::string& scalarName) { + if (!reader_ || scalarName.empty()) { + return; + } + vtkDataArraySelection* sel = reader_->GetPointDataArraySelection(); + if (!sel) { + return; + } + sel->DisableAllArrays(); + sel->EnableArray(scalarName.c_str()); +} + +double TemporalSource::timeAt(int step) const { + if (step < 0 || step >= numSteps_) { + return 0.0; + } + return timeValues_[static_cast(step)]; +} + +bool TemporalSource::updateToStep(int step) { + if (!reader_ || step < 0 || step >= numSteps_) { + return false; + } + // Drive the time-series pipeline via UPDATE_TIME_STEP: vtkHDFReader::RequestData + // recomputes its internal Step from this key on every Update. + vtkInformation* outInfo = reader_->GetOutputInformation(0); + if (!outInfo) { + return false; + } + outInfo->Set(vtkStreamingDemandDrivenPipeline::UPDATE_TIME_STEP(), + timeValues_[static_cast(step)]); + reader_->Update(); + return true; +} + +bool TemporalSource::readStepInto(int step, vtkDataSet* target) { + if (!target || !updateToStep(step)) { + return false; + } + auto* out = vtkDataSet::SafeDownCast(reader_->GetOutputDataObject(0)); + if (!out) { + return false; + } + target->ShallowCopy(out); + target->Modified(); + return true; +} + +bool TemporalSource::sampledScalarRange(const std::string& scalarName, + double out[2], + int maxSamples) { + if (!reader_ || numSteps_ <= 0 || scalarName.empty()) { + return false; + } + const int sampleCount = std::min(numSteps_, std::max(1, maxSamples)); + double lo = 0.0; + double hi = 0.0; + bool any = false; + for (int s = 0; s < sampleCount; ++s) { + // Evenly spaced steps including first and last. + const int step = + sampleCount == 1 + ? 0 + : static_cast((static_cast(s) * (numSteps_ - 1)) / (sampleCount - 1)); + if (!updateToStep(step)) { + continue; + } + auto* out2 = vtkDataSet::SafeDownCast(reader_->GetOutputDataObject(0)); + if (!out2 || !out2->GetPointData()) { + continue; + } + vtkDataArray* arr = out2->GetPointData()->GetArray(scalarName.c_str()); + if (!arr) { + continue; + } + double range[2]; + arr->GetRange(range); + if (!any) { + lo = range[0]; + hi = range[1]; + any = true; + } else { + lo = std::min(lo, range[0]); + hi = std::max(hi, range[1]); + } + } + if (!any) { + return false; + } + out[0] = lo; + out[1] = hi; + return true; +} diff --git a/src/VTKHDFMeshParser.cpp b/src/VTKHDFMeshParser.cpp new file mode 100644 index 0000000..e8523c8 --- /dev/null +++ b/src/VTKHDFMeshParser.cpp @@ -0,0 +1,90 @@ +#include "VTKHDFMeshParser.h" + +#include "TemporalSource.h" +#include "mesh_utils.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +bool hasHDF5Magic(const std::string& filename) { + // HDF5 superblock signature: \x89 H D F \r \n \x1a \n + static const char kMagic[8] = {'\x89', 'H', 'D', 'F', '\r', '\n', '\x1a', '\n'}; + std::string header = readHeader(filename, 8); + if (header.size() < 8) { + return false; + } + return std::equal(std::begin(kMagic), std::end(kMagic), header.begin()); +} + +bool endsWithIgnoreCase(const std::string& value, const std::string& suffix) { + if (value.size() < suffix.size()) { + return false; + } + return std::equal(suffix.rbegin(), suffix.rend(), value.rbegin(), [](char a, char b) { + return std::tolower(static_cast(a)) == + std::tolower(static_cast(b)); + }); +} + +} // namespace + +VTKHDFMeshParser::VTKHDFMeshParser() = default; +VTKHDFMeshParser::~VTKHDFMeshParser() = default; + +bool VTKHDFMeshParser::canParse(const std::string& filename) { + if (!endsWithIgnoreCase(filename, ".vtkhdf")) { + return false; + } + return hasHDF5Magic(filename); +} + +std::vector> VTKHDFMeshParser::parse(const std::string& filename) { + temporal_.reset(); + std::vector> meshes; + + vtkSmartPointer reader = vtkSmartPointer::New(); + reader->SetFileName(filename.c_str()); + if (!reader->CanReadFile(filename.c_str())) { + std::cerr << "Not a readable VTKHDF file: " << filename << '\n'; + return meshes; + } + reader->UpdateInformation(); + + // Collect the available time steps (if any) from the pipeline. + std::vector timeValues; + if (vtkInformation* outInfo = reader->GetOutputInformation(0)) { + if (outInfo->Has(vtkStreamingDemandDrivenPipeline::TIME_STEPS())) { + const int n = outInfo->Length(vtkStreamingDemandDrivenPipeline::TIME_STEPS()); + double* values = outInfo->Get(vtkStreamingDemandDrivenPipeline::TIME_STEPS()); + timeValues.assign(values, values + n); + } + } + + // Read the first step for display. + reader->Update(); + auto* output = vtkDataSet::SafeDownCast(reader->GetOutputDataObject(0)); + if (!output || output->GetNumberOfPoints() == 0) { + std::cerr << "Failed to read VTKHDF dataset: " << filename << '\n'; + return meshes; + } + + // Persistent dataset the mappers point at; playback shallow-copies new frames in. + vtkSmartPointer mesh; + mesh.TakeReference(vtkDataSet::SafeDownCast(output->NewInstance())); + mesh->ShallowCopy(output); + meshes.push_back(mesh); + + if (timeValues.size() > 1) { + temporal_ = std::make_shared(); + temporal_->init(reader, std::move(timeValues)); + } + + return meshes; +} diff --git a/src/VTKMeshParser.cpp b/src/VTKMeshParser.cpp index 923325b..398e9e9 100644 --- a/src/VTKMeshParser.cpp +++ b/src/VTKMeshParser.cpp @@ -4,9 +4,10 @@ #include #include +#include +#include #include #include -#include #include #include @@ -28,7 +29,7 @@ std::vector> VTKMeshParser::parse(const std::string& std::vector> polys; VTKFileType type = detectVTKFileType(filename); if (type == VTKFileType::None) { - std::cerr << "Unrecognized VTK file magic: " << filename << std::endl; + std::cerr << "Unrecognized VTK file magic: " << filename << '\n'; return polys; } if (type == VTKFileType::XML) { @@ -42,16 +43,18 @@ std::vector> VTKMeshParser::parse(const std::string& } } if (type == VTKFileType::Legacy) { - vtkNew reader; + // Generic legacy reader auto-detects POLYDATA, UNSTRUCTURED_GRID, etc. + // Clipped meshes (e.g. from ParaView) become UNSTRUCTURED_GRID. + vtkNew reader; reader->SetFileName(filename.c_str()); reader->Update(); - vtkPolyData* poly = reader->GetOutput(); - if (poly && poly->GetNumberOfPoints() > 0) { - polys.push_back(poly); + vtkDataSet* ds = reader->GetOutput(); + if (ds && ds->GetNumberOfPoints() > 0) { + polys.push_back(ds); return polys; } } - std::cerr << "Failed to read VTK file: " << filename << std::endl; + std::cerr << "Failed to read VTK file: " << filename << '\n'; return polys; } diff --git a/src/ViewerWindow.cpp b/src/ViewerWindow.cpp new file mode 100644 index 0000000..1c45745 --- /dev/null +++ b/src/ViewerWindow.cpp @@ -0,0 +1,770 @@ +#include "ViewerWindow.h" + +#include "ColorBarWidget.h" +#include "PlaybackBar.h" +#include "ScalarVizUtils.h" +#include "TemporalSource.h" +#include "mesh_utils.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr int kOverlayMargin = 12; +constexpr int kOverlayMinWidth = 96; +constexpr int kOverlayMaxWidth = 180; +constexpr double kOverlayHeightRatio = 0.45; +constexpr int kOverlayMinHeight = 150; +constexpr int kOverlayMaxHeight = 260; +constexpr int kTreeOverlayMargin = 16; +constexpr int kTreeOverlayWidth = 360; +constexpr double kTreeOverlayHeightRatio = 0.40; +constexpr int kTreeOverlayMinHeight = 140; +constexpr int kTreeOverlayMaxHeight = 340; +constexpr int kFacetBarMargin = 6; +constexpr int kFacetBarMinWidth = 68; +constexpr int kFacetBarMaxWidth = 140; +constexpr int kPlaybackBarMargin = 16; +constexpr int kPlaybackBarMaxWidth = 760; +constexpr int kPlaybackBarHeight = 44; + +QRect colorBarOverlayGeometry(const QWidget* viewport, const ColorBarWidget* colorBar) { + const int height = std::clamp(static_cast(viewport->height() * kOverlayHeightRatio), + kOverlayMinHeight, + kOverlayMaxHeight); + const int width = std::clamp(colorBar->sizeHint().width(), kOverlayMinWidth, kOverlayMaxWidth); + const int x = std::max(kOverlayMargin, viewport->width() - width - kOverlayMargin); + const int y = std::max(kOverlayMargin, (viewport->height() - height) / 2); + return QRect(x, y, width, height); +} + +QRect treeOverlayGeometry(const QWidget* viewport) { + const int height = std::clamp(static_cast(viewport->height() * kTreeOverlayHeightRatio), + kTreeOverlayMinHeight, + kTreeOverlayMaxHeight); + return QRect(kTreeOverlayMargin, kTreeOverlayMargin, kTreeOverlayWidth, height); +} + +QRect playbackBarGeometry(const QWidget* viewport) { + const int width = + std::min(kPlaybackBarMaxWidth, std::max(280, viewport->width() - 2 * kPlaybackBarMargin)); + const int x = std::max(kPlaybackBarMargin, (viewport->width() - width) / 2); + const int y = + std::max(kPlaybackBarMargin, viewport->height() - kPlaybackBarHeight - kPlaybackBarMargin); + return QRect(x, y, width, kPlaybackBarHeight); +} + +QString QStringFromUtf8(const std::string& value) { + return QString::fromUtf8(value.c_str()); +} + +// Union of selectable scalar fields across all meshes, point fields first then +// cell fields, each group sorted by name. Cell fields are suffixed " (cells)" in +// the colorbar title so the user can tell which association is shown. +std::vector +collectScalarUnion(const std::vector>& meshes) { + std::set pointNames; + std::set cellNames; + for (const auto& mesh : meshes) { + if (!mesh) { + continue; + } + if (auto* pd = mesh->GetPointData()) { + for (int i = 0; i < pd->GetNumberOfArrays(); ++i) { + vtkDataArray* arr = pd->GetArray(i); + if (arr && arr->GetName()) + pointNames.insert(arr->GetName()); + } + } + if (auto* cd = mesh->GetCellData()) { + for (int i = 0; i < cd->GetNumberOfArrays(); ++i) { + vtkDataArray* arr = cd->GetArray(i); + if (arr && arr->GetName()) + cellNames.insert(arr->GetName()); + } + } + } + std::vector fields; + fields.reserve(pointNames.size() + cellNames.size()); + for (const std::string& name : pointNames) { + fields.push_back({name, FieldAssociation::Point}); + } + for (const std::string& name : cellNames) { + fields.push_back({name, FieldAssociation::Cell}); + } + return fields; +} + +QString scalarTitle(const ScalarField& field) { + QString title = QStringFromUtf8(field.name); + if (field.association == FieldAssociation::Cell) { + title += QStringLiteral(" (cells)"); + } + return title; +} + +QIcon partColorIcon(const std::array& rgb) { + constexpr int kSize = 12; + QPixmap pix(kSize, kSize); + pix.fill(Qt::transparent); + + const int r = std::clamp(static_cast(std::lround(rgb[0] * 255.0)), 0, 255); + const int g = std::clamp(static_cast(std::lround(rgb[1] * 255.0)), 0, 255); + const int b = std::clamp(static_cast(std::lround(rgb[2] * 255.0)), 0, 255); + + QPainter painter(&pix); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setPen(QPen(QColor(22, 22, 22, 220), 1.0)); + painter.setBrush(QColor(r, g, b)); + QPolygon poly; + poly << QPoint(2, kSize - 2) << QPoint(kSize / 2, 2) << QPoint(kSize - 2, kSize - 2); + painter.drawPolygon(poly); + return QIcon(pix); +} + +// Swatch list for a categorical scalar: analysis unique values + LUT colors, +// highest value first (top of the bar). +std::vector> categoricalEntries(vtkLookupTable* lut, + const ScalarAnalysis& analysis) { + std::vector> entries; + if (!lut) { + return entries; + } + const auto& uv = analysis.uniqueValues; + int idx = static_cast(uv.size()) - 1; // reverse: highest first + for (auto it = uv.rbegin(); it != uv.rend(); ++it, --idx) { + double rgba[4]; + lut->GetTableValue(idx < 0 ? 0 : idx, rgba); + char label[32]; + const double v = *it; + if (v == std::floor(v)) + std::snprintf(label, sizeof(label), "%g", v); + else + std::snprintf(label, sizeof(label), "%.3g", v); + entries.push_back({QString::fromLatin1(label), + QColor::fromRgbF(static_cast(rgba[0]), + static_cast(rgba[1]), + static_cast(rgba[2]))}); + } + return entries; +} + +// ───────────────────────────────────────────────────────────────────── +// Event filter that keeps VTK interactions predictable: +// - swallow hover-only motion to avoid implicit rotate state, +// - route wheel zoom through a single camera-dolly path, +// - handle scalar cycling/quit hotkeys. +// ───────────────────────────────────────────────────────────────────── +class VtkMouseFilter : public QObject { +public: + explicit VtkMouseFilter(QWidget* vtkRoot, + QWidget* overlayColorBar, + QWidget* overlayTree, + std::function onSpaceCycle, + std::function onViewportResize, + QObject* parent = nullptr) + : QObject(parent), vtkRoot_(vtkRoot), overlayColorBar_(overlayColorBar), + overlayTree_(overlayTree), onSpaceCycle_(std::move(onSpaceCycle)), + onViewportResize_(std::move(onViewportResize)) {} + +protected: + bool eventFilter(QObject* watched, QEvent* event) override { + auto* widget = qobject_cast(watched); + QWidget* vtkRoot = vtkRoot_.data(); + if (!widget || !vtkRoot) { + return QObject::eventFilter(watched, event); + } + + const bool insideVtkWidget = (widget == vtkRoot || vtkRoot->isAncestorOf(widget)); + if (!insideVtkWidget) { + return QObject::eventFilter(watched, event); + } + + QWidget* overlayColorBar = overlayColorBar_.data(); + QWidget* overlayTree = overlayTree_.data(); + + bool insideOverlay = false; + for (QWidget* current = widget; current; current = current->parentWidget()) { + if ((overlayColorBar && current == overlayColorBar) || + qobject_cast(current)) { + insideOverlay = true; + break; + } + } + if (insideOverlay) { + return QObject::eventFilter(watched, event); + } + + switch (event->type()) { + case QEvent::Resize: + if (widget == vtkRoot) { + if (auto* bar = qobject_cast(overlayColorBar)) { + bar->setGeometry(colorBarOverlayGeometry(vtkRoot, bar)); + } + if (overlayTree) { + overlayTree->setGeometry(treeOverlayGeometry(vtkRoot)); + } + if (onViewportResize_) { + onViewportResize_(); + } + } + break; + case QEvent::MouseMove: { + auto* me = static_cast(event); + if (me->buttons() == Qt::NoButton) + return true; // swallow hover‐only moves + break; + } + case QEvent::Wheel: + if (widget != vtkRoot) { + return true; + } + if (auto* we = static_cast(event)) { + auto* vtkView = qobject_cast(vtkRoot); + if (!vtkView || !vtkView->renderWindow()) { + return true; + } + + double steps = 0.0; + if (!we->pixelDelta().isNull()) { + steps = static_cast(we->pixelDelta().y()) / 120.0; + } else { + steps = static_cast(we->angleDelta().y()) / 120.0; + } + if (std::abs(steps) < 1e-6) { + return true; + } + + auto* renderWindow = vtkView->renderWindow(); + auto* renderers = renderWindow->GetRenderers(); + if (!renderers) { + return true; + } + + vtkCollectionSimpleIterator cameraCookie; + renderers->InitTraversal(cameraCookie); + vtkRenderer* renderer = renderers->GetNextRenderer(cameraCookie); + if (!renderer || !renderer->GetActiveCamera()) { + return true; + } + + const double factor = std::pow(1.20, steps); + renderer->GetActiveCamera()->Dolly(factor); + renderer->ResetCameraClippingRange(); + renderWindow->Render(); + return true; + } + return true; + case QEvent::HoverMove: + case QEvent::NativeGesture: + case QEvent::Gesture: + case QEvent::TouchBegin: + case QEvent::TouchUpdate: + case QEvent::TouchEnd: + return true; // block trackpad rotate / pinch gestures + case QEvent::KeyPress: { + auto* ke = static_cast(event); + if (ke->key() == Qt::Key_Space && onSpaceCycle_) { + onSpaceCycle_(); + return true; + } + if (ke->key() == Qt::Key_Q) { + QApplication::quit(); + return true; + } + break; + } + case QEvent::ShortcutOverride: { + auto* ke = static_cast(event); + if (ke->key() == Qt::Key_Space || ke->key() == Qt::Key_Q) { + ke->accept(); + return true; + } + break; + } + default: + break; + } + return QObject::eventFilter(watched, event); + } + +private: + QPointer vtkRoot_; + QPointer overlayColorBar_; + QPointer overlayTree_; + std::function onSpaceCycle_; + std::function onViewportResize_; +}; + +} // namespace + +// ═════════════════════════════════════════════════════════════════════ +ViewerWindow::ViewerWindow(MeshLoadResult loadResult, const ViewerOptions& options, QWidget* parent) + : QMainWindow(parent), load_(std::move(loadResult)), options_(options), + temporal_(load_.temporal) { + // Title: "vv - .../parent/stem.ext" + if (!load_.meshes.names.empty()) { + QFileInfo fi(QStringFromUtf8(load_.meshes.names.front())); + setWindowTitle(QStringLiteral("vv - …/") + fi.dir().dirName() + "/" + fi.fileName()); + } + resize(1300, 980); + + const auto& meshes = load_.meshes.meshes; + partColors_.reserve(meshes.size()); + for (size_t i = 0; i < meshes.size(); ++i) { + if (i < load_.meshes.partHasColors.size() && load_.meshes.partHasColors[i] && + i < load_.meshes.partColors.size()) { + partColors_.push_back(load_.meshes.partColors[i]); + } else { + partColors_.push_back(generateDistinctColor(static_cast(i))); + } + } + + buildViewport(); + + if (options_.commonCatLut) { + std::vector ptrs; + ptrs.reserve(meshes.size()); + std::transform(meshes.begin(), meshes.end(), std::back_inserter(ptrs), [](const auto& m) { + return m.GetPointer(); + }); + renderer_.setSharedCatAnalysis(buildCommonCatAnalysis(ptrs)); + } + + qApp->installEventFilter(new VtkMouseFilter( + vtkWidget_, + colorBar_, + partsTree_, + [this]() { cycleScalar(); }, + [this]() { onViewportResize(); }, + this)); + + QTimer::singleShot(0, this, [this]() { + colorBar_->setGeometry(colorBarOverlayGeometry(vtkWidget_, colorBar_)); + partsTree_->setGeometry(treeOverlayGeometry(vtkWidget_)); + }); + + if (options_.explodeView) { + setupFacetMode(); + } else { + setupNormalMode(); + } + + vtkWidget_->setFocus(); +} + +void ViewerWindow::buildViewport() { + auto* central = new QWidget(this); + auto* layout = new QHBoxLayout(central); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + // The VTK 3‑D view + vtkWidget_ = new QVTKOpenGLNativeWidget(central); + vtkWidget_->setFocusPolicy(Qt::StrongFocus); + vtkWidget_->setAttribute(Qt::WA_AcceptTouchEvents, false); + layout->addWidget(vtkWidget_, 1); + setCentralWidget(central); + + auto renderWindow = vtkSmartPointer::New(); + renderWindow->SetMultiSamples(0); + renderWindow->SetDesiredUpdateRate(120.0); + vtkWidget_->setRenderWindow(renderWindow); + + renderer_.setRenderContext(renderWindow, vtkWidget_->interactor()); + + colorBar_ = new ColorBarWidget(vtkWidget_); + colorBar_->setVisible(false); + colorBar_->setAttribute(Qt::WA_TransparentForMouseEvents, false); + colorBar_->setFocusPolicy(Qt::NoFocus); + colorBar_->setGeometry(colorBarOverlayGeometry(vtkWidget_, colorBar_)); + colorBar_->raise(); + + partsTree_ = new QTreeWidget(vtkWidget_); + partsTree_->setColumnCount(1); + partsTree_->setHeaderHidden(true); + partsTree_->setRootIsDecorated(true); + partsTree_->setUniformRowHeights(true); + partsTree_->setIndentation(18); + partsTree_->setGeometry(treeOverlayGeometry(vtkWidget_)); + partsTree_->setVisible(false); + partsTree_->setFocusPolicy(Qt::NoFocus); + partsTree_->setHorizontalScrollMode(QAbstractItemView::ScrollPerPixel); + partsTree_->setStyleSheet("QTreeWidget {" + " background: rgba(0,0,0,0);" + " color: #E2E2E2;" + " outline: none;" + " padding: 2px;" + "}"); + partsTree_->raise(); +} + +// ── facet (exploded) mode ────────────────────────────────────────────── +void ViewerWindow::setupFacetMode() { + renderer_.setupFacetGrid(load_.meshes.meshes, load_.meshes.names, partColors_); + renderer_.startFacetGrid(); + colorBar_->setVisible(false); + partsTree_->setVisible(false); + + const size_t panelCount = renderer_.getFacetPanelCount(); + facetColorBars_.reserve(panelCount); + for (size_t panelIndex = 0; panelIndex < panelCount; ++panelIndex) { + FacetPanelInfo panelInfo; + if (!renderer_.getFacetPanelInfo(panelIndex, panelInfo)) { + continue; + } + + auto* panelBar = new ColorBarWidget(vtkWidget_); + panelBar->setFocusPolicy(Qt::NoFocus); + panelBar->setTitle(QStringFromUtf8(panelInfo.title)); + panelBar->setRange(panelInfo.globalRange[0], panelInfo.globalRange[1]); + + if (panelInfo.analysis.categorical) { + vtkLookupTable* lut = renderer_.getFacetPanelLUT(panelIndex); + panelBar->setCategorical(categoricalEntries(lut, panelInfo.analysis)); + } else { + panelBar->setClipRange(panelInfo.clipRange[0], panelInfo.clipRange[1]); + } + panelBar->setVisible(true); + + QObject::connect(panelBar, + &ColorBarWidget::clipRangeChanged, + this, + [this, panelIndex, panelBar](double lo, double hi) { + if (renderer_.setFacetPanelClipRange(panelIndex, lo, hi)) { + FacetPanelInfo updated; + if (renderer_.getFacetPanelInfo(panelIndex, updated)) { + panelBar->setClipRange(updated.clipRange[0], updated.clipRange[1]); + } + } + }); + + facetColorBars_.push_back(panelBar); + } + + layoutFacetColorBars(); + QTimer::singleShot(0, this, [this]() { layoutFacetColorBars(); }); +} + +// ── normal (single-view) mode ────────────────────────────────────────── +void ViewerWindow::setupNormalMode() { + renderer_.setup(load_.meshes.meshes, load_.meshes.names, partColors_); + renderer_.start(); + + buildPartsTree(); + + QObject::connect(colorBar_, + &ColorBarWidget::clipRangeChanged, + this, + [this](double lo, double hi) { renderer_.setClipRange(lo, hi); }); + + scalarFields_ = collectScalarUnion(load_.meshes.meshes); + if (!scalarFields_.empty()) { + applyScalarAtIndex(0); + } else { + applyNoScalar(); + } + + if (temporal_ && temporal_->playable()) { + setupPlayback(); + } +} + +void ViewerWindow::buildPartsTree() { + partsTree_->clear(); + for (const MeshGroup& group : load_.meshes.groups) { + auto* groupItem = new QTreeWidgetItem(partsTree_); + groupItem->setText(0, QStringFromUtf8(group.name)); + groupItem->setFlags(groupItem->flags() | Qt::ItemIsUserCheckable); + groupItem->setCheckState(0, Qt::Checked); + + for (size_t partIndex : group.partIndices) { + if (partIndex >= load_.meshes.partNames.size()) { + continue; + } + auto* partItem = new QTreeWidgetItem(groupItem); + partItem->setText(0, QStringFromUtf8(load_.meshes.partNames[partIndex])); + if (partIndex < partColors_.size()) { + partItem->setIcon(0, partColorIcon(partColors_[partIndex])); + } + partItem->setFlags(partItem->flags() | Qt::ItemIsUserCheckable); + partItem->setCheckState(0, Qt::Checked); + partItem->setData(0, Qt::UserRole, static_cast(partIndex)); + } + groupItem->setExpanded(group.partIndices.size() <= 8); + } + partsTree_->setVisible(!load_.meshes.groups.empty()); + + QObject::connect( + partsTree_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem* item, int column) { + if (!item || column != 0) { + return; + } + + const bool checked = (item->checkState(0) == Qt::Checked); + QSignalBlocker block(partsTree_); + + if (item->childCount() > 0) { + for (int childIndex = 0; childIndex < item->childCount(); ++childIndex) { + QTreeWidgetItem* child = item->child(childIndex); + child->setCheckState(0, checked ? Qt::Checked : Qt::Unchecked); + const size_t partIndex = + static_cast(child->data(0, Qt::UserRole).toULongLong()); + renderer_.setPartVisible(partIndex, checked); + } + return; + } + + const size_t partIndex = static_cast(item->data(0, Qt::UserRole).toULongLong()); + renderer_.setPartVisible(partIndex, checked); + + QTreeWidgetItem* parent = item->parent(); + if (!parent) { + return; + } + int checkedChildren = 0; + for (int childIndex = 0; childIndex < parent->childCount(); ++childIndex) { + if (parent->child(childIndex)->checkState(0) == Qt::Checked) { + ++checkedChildren; + } + } + if (checkedChildren == 0) { + parent->setCheckState(0, Qt::Unchecked); + } else if (checkedChildren == parent->childCount()) { + parent->setCheckState(0, Qt::Checked); + } else { + parent->setCheckState(0, Qt::PartiallyChecked); + } + }); +} + +// ── playback toolbar for temporal (time-series) meshes ───────────────── +void ViewerWindow::setupPlayback() { + const int numSteps = temporal_->steps(); + playbackBar_ = new PlaybackBar(numSteps, vtkWidget_); + playbackBar_->setGeometry(playbackBarGeometry(vtkWidget_)); + playbackBar_->raise(); + playbackBar_->show(); + + playTimer_ = new QTimer(this); + + QObject::connect(playTimer_, &QTimer::timeout, this, [this, numSteps]() { + int next = playbackBar_->currentStep() + 1; + if (next >= numSteps) { + if (playbackBar_->loopEnabled()) { + next = 0; + } else { + playTimer_->stop(); + playbackBar_->setPlaying(false); + return; + } + } + showFrame(next); + }); + + QObject::connect(playbackBar_, &PlaybackBar::playToggled, this, [this, numSteps](bool playing) { + if (playing) { + // Restart from the beginning if paused at the last frame. + if (playbackBar_->currentStep() >= numSteps - 1) { + showFrame(0); + } + applyPlayTimerInterval(); + playTimer_->start(); + } else { + playTimer_->stop(); + } + }); + + QObject::connect( + playbackBar_, &PlaybackBar::stepRequested, this, [this](int step) { showFrame(step); }); + + QObject::connect(playbackBar_, &PlaybackBar::speedChanged, this, [this](double) { + if (playTimer_->isActive()) { + applyPlayTimerInterval(); + } + }); + + QTimer::singleShot( + 0, this, [this]() { playbackBar_->setGeometry(playbackBarGeometry(vtkWidget_)); }); +} + +void ViewerWindow::showFrame(int step) { + if (!temporal_ || step < 0 || step >= temporal_->steps() || load_.meshes.meshes.empty()) { + return; + } + temporal_->readStepInto(step, load_.meshes.meshes.front()); + renderer_.refreshAfterDataChange(); + currentPlaybackStep_ = step; + if (playbackBar_) { + playbackBar_->setStep(step, temporal_->timeAt(step)); + } +} + +void ViewerWindow::applyPlayTimerInterval() { + const double fps = 15.0 * playbackBar_->speedMultiplier(); + playTimer_->setInterval(std::max(1, static_cast(std::round(1000.0 / fps)))); +} + +// ── scalar handling ──────────────────────────────────────────────────── +void ViewerWindow::applyNoScalar() { + renderer_.clearActiveScalar(); + colorBar_->setVisible(false); + colorBar_->setTitle("Geometry"); + activeScalarIdx_ = -1; +} + +void ViewerWindow::applyScalarAtIndex(int index) { + if (index < 0 || index >= static_cast(scalarFields_.size())) { + applyNoScalar(); + return; + } + + const ScalarField& field = scalarFields_[static_cast(index)]; + const std::string& scalarName = field.name; + + // Temporal: restrict frame reads to this array and reload the current frame so + // the mesh holds it before the scalar is applied. Reader array selection only + // covers point data, so cell fields fall back to reading all arrays per frame. + const bool temporalPoint = + temporal_ && temporal_->playable() && field.association == FieldAssociation::Point; + if (temporalPoint) { + temporal_->setActiveArray(scalarName); + temporal_->readStepInto(currentPlaybackStep_, load_.meshes.meshes.front()); + } + + if (!renderer_.setActiveScalar(scalarName, field.association)) { + return; + } + activeScalarIdx_ = index; + colorBar_->setTitle(scalarTitle(field)); + + // For temporal data, fix the color range to the union across sampled steps so + // the colormap does not flicker as frames advance. + if (temporalPoint) { + auto cached = temporalRangeCache_.find(scalarName); + if (cached == temporalRangeCache_.end()) { + double sampled[2]; + if (temporal_->sampledScalarRange(scalarName, sampled)) { + cached = + temporalRangeCache_.emplace(scalarName, std::array{sampled[0], sampled[1]}) + .first; + } + } + if (cached != temporalRangeCache_.end()) { + renderer_.setActiveScalarRange(cached->second[0], cached->second[1]); + } + } + + double globalRange[2] = {0.0, 1.0}; + if (!renderer_.getActiveScalarGlobalRange(globalRange)) { + colorBar_->setVisible(false); + return; + } + colorBar_->setVisible(true); + colorBar_->setRange(globalRange[0], globalRange[1]); + + const ScalarAnalysis& analysis = renderer_.getActiveScalarAnalysis(); + if (analysis.categorical) { + colorBar_->setCategorical(categoricalEntries(renderer_.getActiveLUT(), analysis)); + } else { + colorBar_->clearCategorical(); + double clipRange[2] = {globalRange[0], globalRange[1]}; + renderer_.getClipRange(clipRange); + colorBar_->setClipRange(clipRange[0], clipRange[1]); + } +} + +void ViewerWindow::cycleScalar() { + if (scalarFields_.empty()) { + applyNoScalar(); + return; + } + if (activeScalarIdx_ < 0) { + applyScalarAtIndex(0); + return; + } + const int next = activeScalarIdx_ + 1; + if (next >= static_cast(scalarFields_.size())) { + applyNoScalar(); + return; + } + applyScalarAtIndex(next); +} + +// ── overlay layout ───────────────────────────────────────────────────── +void ViewerWindow::onViewportResize() { + layoutFacetColorBars(); + if (playbackBar_) { + playbackBar_->setGeometry(playbackBarGeometry(vtkWidget_)); + playbackBar_->raise(); + } +} + +void ViewerWindow::layoutFacetColorBars() { + if (facetColorBars_.empty()) { + return; + } + const int viewportWidth = vtkWidget_->width(); + const int viewportHeight = vtkWidget_->height(); + const int normalTargetHeight = std::clamp( + static_cast(viewportHeight * kOverlayHeightRatio), kOverlayMinHeight, kOverlayMaxHeight); + + for (size_t panelIndex = 0; panelIndex < facetColorBars_.size(); ++panelIndex) { + FacetPanelInfo panelInfo; + if (!renderer_.getFacetPanelInfo(panelIndex, panelInfo)) { + continue; + } + + const int panelX = static_cast(std::round(panelInfo.viewport[0] * viewportWidth)); + const int panelY = static_cast(std::round((1.0 - panelInfo.viewport[3]) * viewportHeight)); + const int panelW = + std::max(1, + static_cast( + std::round((panelInfo.viewport[2] - panelInfo.viewport[0]) * viewportWidth))); + const int panelH = + std::max(1, + static_cast( + std::round((panelInfo.viewport[3] - panelInfo.viewport[1]) * viewportHeight))); + + ColorBarWidget* bar = facetColorBars_[panelIndex]; + const int margin = kFacetBarMargin; + const int barW = std::clamp(bar->sizeHint().width(), kFacetBarMinWidth, kFacetBarMaxWidth); + int barH = normalTargetHeight; + barH = std::min(barH, std::max(40, panelH - 2 * margin)); + const int barX = panelX + std::max(0, panelW - barW - margin); + const int barY = panelY + std::max(1, (panelH - barH) / 2); + + bar->setGeometry(barX, barY, barW, barH); + bar->raise(); + } +} diff --git a/src/XMLMeshParser.cpp b/src/XMLMeshParser.cpp index 6eb3c68..a543c83 100644 --- a/src/XMLMeshParser.cpp +++ b/src/XMLMeshParser.cpp @@ -41,7 +41,7 @@ std::vector> XMLMeshParser::parse(const std::string& vtkSmartPointer root = vtkSmartPointer::Take( vtkXMLUtilities::ReadElementFromFile(filename.c_str())); if (!root) { - std::cerr << "Failed to read XML: " << filename << std::endl; + std::cerr << "Failed to read XML: " << filename << '\n'; return polys; } vtkXMLDataElement* body = root->FindNestedElementWithName("DIFBody"); @@ -49,7 +49,7 @@ std::vector> XMLMeshParser::parse(const std::string& body = root; vtkXMLDataElement* vols = body->FindNestedElementWithName("Volumes"); if (!vols) { - std::cerr << "No in XML: " << filename << std::endl; + std::cerr << "No in XML: " << filename << '\n'; return polys; } for (int i = 0; i < vols->GetNumberOfNestedElements(); ++i) { @@ -64,8 +64,10 @@ std::vector> XMLMeshParser::parse(const std::string& continue; vtkNew pts; pts->SetNumberOfPoints(static_cast(verts.size() / 3)); - for (vtkIdType vi = 0; vi < static_cast(verts.size() / 3); ++vi) - pts->SetPoint(vi, verts[3 * vi + 0], verts[3 * vi + 1], verts[3 * vi + 2]); + for (vtkIdType vi = 0; vi < static_cast(verts.size() / 3); ++vi) { + const size_t vi3 = static_cast(vi) * 3; + pts->SetPoint(vi, verts[vi3], verts[vi3 + 1], verts[vi3 + 2]); + } vtkXMLDataElement* polyElem = vol->FindNestedElementWithName("Polygons"); if (!polyElem) continue; @@ -104,9 +106,10 @@ std::vector> XMLMeshParser::parse(const std::string& an->SetNumberOfComponents(3); an->SetNumberOfTuples(static_cast(n.size() / 3)); for (vtkIdType ni = 0; ni < static_cast(n.size() / 3); ++ni) { - const float tuple[3] = {static_cast(n[3 * ni]), - static_cast(n[3 * ni + 1]), - static_cast(n[3 * ni + 2])}; + const size_t ni3 = static_cast(ni) * 3; + const float tuple[3] = {static_cast(n[ni3]), + static_cast(n[ni3 + 1]), + static_cast(n[ni3 + 2])}; an->SetTypedTuple(ni, tuple); } poly->GetPointData()->SetNormals(an); diff --git a/src/include/ColorBarWidget.h b/src/include/ColorBarWidget.h index ed41be8..eda4a9e 100644 --- a/src/include/ColorBarWidget.h +++ b/src/include/ColorBarWidget.h @@ -2,8 +2,12 @@ #include #include +#include +#include #include #include +#include +#include /// A vertical colorbar widget with draggable clip handles, inspired by /// the mapping-system UIs of CARTO and RHYTHMIA. @@ -28,12 +32,13 @@ class ColorBarWidget : public QWidget { /// Title displayed above the bar (e.g. scalar name). void setTitle(const QString& title); - double clipLower() const { - return clipMin_; - } - double clipUpper() const { - return clipMax_; - } + /// Switch to categorical (indexed) display mode. + /// Entries are (value, color, label) sorted by value ascending. + /// Clip handles are hidden; min/max labels still shown. + void setCategorical(const std::vector>& entries); + + /// Restore continuous gradient mode. + void clearCategorical(); QSize sizeHint() const override; QSize minimumSizeHint() const override; @@ -63,6 +68,10 @@ class ColorBarWidget : public QWidget { double clipMax_ = 1.0; QString title_; + // categorical mode + bool categorical_ = false; + std::vector> catEntries_; // (label, color), bottom→top order + // ── interaction state ───────────────────────────────────────────── enum Handle { None, Lower, Upper }; Handle dragHandle_ = None; @@ -75,7 +84,6 @@ class ColorBarWidget : public QWidget { double valueToY(double value) const; double yToValue(double y) const; Handle hitTestHandle(const QPointF& pos, double tolerance = 10.0) const; - QColor colorForValue(double value) const; void emitClipChanged(); void showInlineEditor(Handle handle); void commitInlineEditor(); diff --git a/src/include/JsonMeshParser.h b/src/include/JsonMeshParser.h new file mode 100644 index 0000000..ae8ffa6 --- /dev/null +++ b/src/include/JsonMeshParser.h @@ -0,0 +1,9 @@ +#pragma once +#include "MeshParser.h" + +class JsonMeshParser : public MeshParser { +public: + ~JsonMeshParser() override; + std::vector> parse(const std::string& filename) override; + bool canParse(const std::string& filename) override; +}; diff --git a/src/include/MeshLoading.h b/src/include/MeshLoading.h index 9424f63..48bc446 100644 --- a/src/include/MeshLoading.h +++ b/src/include/MeshLoading.h @@ -1,10 +1,14 @@ #pragma once +#include +#include #include #include #include #include +class TemporalSource; + struct MeshGroup { std::string name; std::vector partIndices; @@ -14,6 +18,8 @@ struct LoadedMeshes { std::vector> meshes; std::vector names; std::vector partNames; + std::vector> partColors; + std::vector partHasColors; std::vector groups; }; @@ -22,6 +28,9 @@ struct MeshLoadResult { int exitCode = 0; std::string error; LoadedMeshes meshes; + // Non-null and playable when a temporal VTKHDF file was loaded; meshes[0] is the + // rendered object playback streams successive frames into. + std::shared_ptr temporal; }; MeshLoadResult loadMeshes(const std::vector& meshfiles, bool explodeView); diff --git a/src/include/MeshParser.h b/src/include/MeshParser.h index 9fdff4f..1d14c32 100644 --- a/src/include/MeshParser.h +++ b/src/include/MeshParser.h @@ -1,5 +1,6 @@ #pragma once #include +#include #include #include diff --git a/src/include/MeshRenderer.h b/src/include/MeshRenderer.h index f3d41db..15bf61c 100644 --- a/src/include/MeshRenderer.h +++ b/src/include/MeshRenderer.h @@ -1,15 +1,20 @@ #pragma once +#include "ScalarVizUtils.h" + #include #include #include #include #include #include +#include #include #include #include #include +class vtkCallbackCommand; + struct RendererContext { vtkSmartPointer window; std::vector> actors; @@ -21,6 +26,7 @@ struct FacetPanelInfo { double globalRange[2] = {0.0, 1.0}; double clipRange[2] = {0.0, 1.0}; double viewport[4] = {0.0, 0.0, 1.0, 1.0}; + ScalarAnalysis analysis; }; class MeshRenderer { @@ -34,17 +40,28 @@ class MeshRenderer { const std::vector>& colorsHex); void start(); - const std::vector& getScalarNames() const; - bool setActiveScalar(const std::string& scalarName); + // When set, all categorical scalars use this shared LUT instead of per-scalar detection. + // Pass an empty ScalarAnalysis to clear. + void setSharedCatAnalysis(const ScalarAnalysis& shared); + + bool setActiveScalar(const std::string& scalarName, FieldAssociation association); void clearActiveScalar(); + // Re-apply the current scalar mapping after the underlying mesh data changed + // (e.g. a new playback frame was shallow-copied in), keeping the color range + // fixed, then re-render. + void refreshAfterDataChange(); + // Override the active scalar's color range (used to fix a stable range across a + // whole time series instead of the current frame's range). + void setActiveScalarRange(double minValue, double maxValue); bool getActiveScalarGlobalRange(double outRange[2]) const; + const ScalarAnalysis& getActiveScalarAnalysis() const; + vtkLookupTable* getActiveLUT() const; void getClipRange(double outRange[2]) const; bool setClipRange(double minValue, double maxValue); - size_t getPartCount() const; bool setPartVisible(size_t partIndex, bool visible); - bool isPartVisible(size_t partIndex) const; size_t getFacetPanelCount() const; bool getFacetPanelInfo(size_t panelIndex, FacetPanelInfo& outInfo) const; + vtkLookupTable* getFacetPanelLUT(size_t panelIndex) const; bool setFacetPanelClipRange(size_t panelIndex, double minValue, double maxValue); void setupFacetGrid(const std::vector>& meshes, @@ -59,17 +76,22 @@ class MeshRenderer { vtkSmartPointer interactor; std::vector> sceneMeshes; std::vector> mappers; - std::vector availableScalars; std::string activeScalarName; + FieldAssociation activeScalarAssociation = FieldAssociation::Point; + ScalarAnalysis activeScalarAnalysis; + ScalarAnalysis sharedCatAnalysis; // non-empty = override per-scalar detection double activeScalarGlobalRange[2] = {0.0, 1.0}; double clipRange[2] = {0.0, 1.0}; struct FacetPanelState { vtkSmartPointer mapper; std::string title; + ScalarAnalysis analysis; double globalRange[2] = {0.0, 1.0}; double clipRange[2] = {0.0, 1.0}; double viewport[4] = {0.0, 0.0, 1.0, 1.0}; }; std::vector facetPanels; + // Keeps the facet-grid cameras synchronized (observer shared by all panels). + vtkSmartPointer camLinkCb_; bool embeddedMode = false; }; diff --git a/src/include/PlaybackBar.h b/src/include/PlaybackBar.h new file mode 100644 index 0000000..0cb304b --- /dev/null +++ b/src/include/PlaybackBar.h @@ -0,0 +1,48 @@ +#pragma once + +#include + +class QLabel; +class QSlider; +class QComboBox; +class QToolButton; + +// Bottom-overlay media bar for temporal (playable) meshes: play/pause, a scrub +// slider, a frame/time readout, a speed multiplier, and a loop toggle. +// +// The widget is intent-only: it emits what the user asked for and reflects state +// pushed back via setStep()/setPlaying(). The owner drives the actual frame timer. +class PlaybackBar : public QWidget { + Q_OBJECT +public: + explicit PlaybackBar(int numSteps, QWidget* parent = nullptr); + + int currentStep() const; + double speedMultiplier() const; + bool loopEnabled() const; + bool isPlaying() const { + return playing_; + } + + // Reflect externally driven state without re-emitting signals. + void setStep(int step, double timeValue); + void setPlaying(bool playing); + +signals: + void playToggled(bool playing); + void stepRequested(int step); + void speedChanged(double multiplier); + void loopToggled(bool loop); + +private: + void updateReadout(int step, double timeValue); + + int numSteps_ = 0; + bool playing_ = false; + + QToolButton* playButton_ = nullptr; + QSlider* slider_ = nullptr; + QLabel* readout_ = nullptr; + QComboBox* speedBox_ = nullptr; + QToolButton* loopButton_ = nullptr; +}; diff --git a/src/include/ScalarVizUtils.h b/src/include/ScalarVizUtils.h index 46b8cc6..0697d67 100644 --- a/src/include/ScalarVizUtils.h +++ b/src/include/ScalarVizUtils.h @@ -1,29 +1,68 @@ #pragma once +#include #include #include #include #include #include -#include -#include #include +// Whether a scalar array lives on the mesh points or on its cells. VTK keeps the +// two in separate attribute containers (GetPointData/GetCellData) and a mapper +// must be told which one to color by, so the association is threaded through the +// whole scalar pipeline alongside the array name. +enum class FieldAssociation { Point, Cell }; + +// A selectable scalar: its array name plus where it lives. Used as the unit the +// viewer cycles through with the Space key. +struct ScalarField { + std::string name; + FieldAssociation association = FieldAssociation::Point; +}; + +// Result of scalar field analysis — computed once, passed around. +struct ScalarAnalysis { + bool categorical = false; + std::set uniqueValues; // populated iff categorical == true +}; + +// Fetch a named array from the point- or cell-data container of a dataset. +// Returns nullptr if the mesh is null or the array is absent. +vtkDataArray* +arrayForAssociation(vtkDataSet* mesh, const std::string& name, FieldAssociation association); + +// Inspect scalar field across all meshes: detect categorical (2–20 unique integer-domain values) +// vs continuous. Integer-typed VTK arrays are always treated as categorical candidates. +ScalarAnalysis analyzeScalar(const std::vector& meshes, + const std::string& scalarName, + FieldAssociation association); + +// Build a shared categorical analysis from the union of unique values across ALL categorical +// scalar fields (point and cell) in the given meshes. Non-categorical scalars are skipped. +// Used with --common-cat-lut to assign consistent value→color mapping across scalars. +ScalarAnalysis buildCommonCatAnalysis(const std::vector& meshes); + bool computeScalarGlobalRange(const std::vector& meshes, const std::string& scalarName, + FieldAssociation association, double outRange[2]); +// Continuous rainbow LUT. vtkSmartPointer createDefaultLookupTable(const double range[2]); -void applyLookupTableRange(vtkLookupTable* lut, const double range[2]); +// Categorical LUT using tab10 (n≤10) or tab20 (n≤20). Uses indexed lookup. +vtkSmartPointer createCategoricalLookupTable(const std::set& uniqueValues); -void updateScalarBar(vtkScalarBarActor* bar, - vtkRenderWindow* window, - vtkLookupTable* lut, - const std::string& title, - bool show); +// Build LUT from a pre-computed ScalarAnalysis. +vtkSmartPointer buildLookupTable(const ScalarAnalysis& analysis, + const double range[2]); + +void applyLookupTableRange(vtkLookupTable* lut, const double range[2]); -bool setMapperScalarFromPointData(vtkDataSet* mesh, - vtkDataSetMapper* mapper, - const std::string& scalarName, - const double range[2]); +bool setMapperScalar(vtkDataSet* mesh, + vtkDataSetMapper* mapper, + const std::string& scalarName, + FieldAssociation association, + const double range[2], + const ScalarAnalysis& analysis); diff --git a/src/include/TemporalSource.h b/src/include/TemporalSource.h new file mode 100644 index 0000000..8d04d14 --- /dev/null +++ b/src/include/TemporalSource.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include +#include + +class vtkHDFReader; + +// Wraps a live vtkHDFReader for a temporal (time-series) VTKHDF file. Frames are +// streamed on demand — only the current step is held in memory — because result +// files routinely span thousands of steps and gigabytes on disk. +class TemporalSource { +public: + TemporalSource(); + ~TemporalSource(); + + // Number of time steps; >1 means the file is playable. + int steps() const { + return numSteps_; + } + bool playable() const { + return numSteps_ > 1; + } + double timeAt(int step) const; + + // Read the given step and shallow-copy its dataset into `target` (the rendered + // object the mappers point at). Returns false on out-of-range or read failure. + bool readStepInto(int step, vtkDataSet* target); + + // Union of a point-data array's range across up to maxSamples evenly spaced + // steps. Used to fix a stable color range for the whole animation. + bool sampledScalarRange(const std::string& scalarName, double out[2], int maxSamples = 16); + + // Restrict per-frame reads to a single point-data array. Static geometry is + // cached (UseCache) and the other arrays are skipped, so streaming a frame only + // touches the array actually being colored — the dominant playback speed-up. + void setActiveArray(const std::string& scalarName); + + // Called by the parser once the reader is constructed and information is read. + void init(const vtkSmartPointer& reader, std::vector timeValues); + +private: + bool updateToStep(int step); + + vtkSmartPointer reader_; + std::vector timeValues_; + int numSteps_ = 0; +}; diff --git a/src/include/VTKHDFMeshParser.h b/src/include/VTKHDFMeshParser.h new file mode 100644 index 0000000..e48a38f --- /dev/null +++ b/src/include/VTKHDFMeshParser.h @@ -0,0 +1,26 @@ +#pragma once + +#include "MeshParser.h" + +#include + +class TemporalSource; + +// Parses VTKHDF files (.vtkhdf — HDF5-backed VTK). Loads the first time step for +// rendering; if the file is temporal, exposes a TemporalSource for playback. +class VTKHDFMeshParser : public MeshParser { +public: + VTKHDFMeshParser(); + ~VTKHDFMeshParser() override; + + std::vector> parse(const std::string& filename) override; + bool canParse(const std::string& filename) override; + + // Valid after a successful parse(); null if the file is not temporal. + std::shared_ptr temporal() const { + return temporal_; + } + +private: + std::shared_ptr temporal_; +}; diff --git a/src/include/ViewerWindow.h b/src/include/ViewerWindow.h new file mode 100644 index 0000000..0ce1471 --- /dev/null +++ b/src/include/ViewerWindow.h @@ -0,0 +1,76 @@ +#pragma once + +#include "MeshLoading.h" +#include "MeshRenderer.h" +#include "ScalarVizUtils.h" + +#include +#include +#include +#include +#include +#include +#include + +class ColorBarWidget; +class PlaybackBar; +class QTimer; +class QTreeWidget; +class QVTKOpenGLNativeWidget; +class TemporalSource; + +struct ViewerOptions { + bool explodeView = false; + bool commonCatLut = false; +}; + +// Main application window: owns the VTK viewport, the overlay widgets +// (colorbar, parts tree, playback bar) and all viewer state previously held as +// lambda captures in main(). +class ViewerWindow : public QMainWindow { + Q_OBJECT +public: + ViewerWindow(MeshLoadResult loadResult, const ViewerOptions& options, QWidget* parent = nullptr); + +private: + // ── setup ───────────────────────────────────────────────────────── + void buildViewport(); + void setupFacetMode(); + void setupNormalMode(); + void buildPartsTree(); + void setupPlayback(); + + // ── scalar handling ─────────────────────────────────────────────── + void applyScalarAtIndex(int index); + void applyNoScalar(); + void cycleScalar(); + + // ── layout / playback ───────────────────────────────────────────── + void layoutFacetColorBars(); + void onViewportResize(); + void showFrame(int step); + void applyPlayTimerInterval(); + + // ── state ───────────────────────────────────────────────────────── + MeshLoadResult load_; + ViewerOptions options_; + std::vector> partColors_; + + MeshRenderer renderer_; + QVTKOpenGLNativeWidget* vtkWidget_ = nullptr; + ColorBarWidget* colorBar_ = nullptr; + QTreeWidget* partsTree_ = nullptr; + std::vector facetColorBars_; + + std::vector scalarFields_; + int activeScalarIdx_ = -1; + + // Temporal (playable) support: when a time-series file is loaded, the color + // range is fixed across the whole animation (sampled once per scalar) so the + // colormap stays stable while frames advance. + std::shared_ptr temporal_; + std::map> temporalRangeCache_; + QPointer playbackBar_; + QTimer* playTimer_ = nullptr; + int currentPlaybackStep_ = 0; +}; diff --git a/src/main_qt.cpp b/src/main_qt.cpp index 2206964..b018a90 100644 --- a/src/main_qt.cpp +++ b/src/main_qt.cpp @@ -1,278 +1,77 @@ -#include "ColorBarWidget.h" #include "MeshLoading.h" -#include "MeshRenderer.h" -#include "mesh_utils.h" +#include "ViewerWindow.h" #include "version.h" -#include #include -#include -#include -#include -#include -#include -#include -#include -#include +#include +#include +#include +#include +#include +#include +#include +#include #include -#include -#include +#include #include -#include -#include -#include -#include -#include +#ifdef _WIN32 +#include +#endif #include -#include #include -#include #include #include -#include -#include -#include -#include -#include +#include +#include +#include +#include +#include #include -#include +#include namespace { -constexpr int kOverlayMargin = 12; -constexpr int kOverlayWidth = 96; -constexpr double kOverlayHeightRatio = 0.45; -constexpr int kOverlayMinHeight = 150; -constexpr int kOverlayMaxHeight = 260; -constexpr int kTreeOverlayMargin = 16; -constexpr int kTreeOverlayWidth = 360; -constexpr double kTreeOverlayHeightRatio = 0.40; -constexpr int kTreeOverlayMinHeight = 140; -constexpr int kTreeOverlayMaxHeight = 340; -constexpr int kFacetBarMargin = 6; -constexpr int kFacetBarWidth = 68; -QRect colorBarOverlayGeometry(const QWidget* viewport) { - const int height = std::clamp(static_cast(viewport->height() * kOverlayHeightRatio), - kOverlayMinHeight, - kOverlayMaxHeight); - const int x = std::max(kOverlayMargin, viewport->width() - kOverlayWidth - kOverlayMargin); - const int y = std::max(kOverlayMargin, (viewport->height() - height) / 2); - return QRect(x, y, kOverlayWidth, height); +std::string QStringToUtf8(const QString& value) { + const QByteArray bytes = value.toUtf8(); + return std::string(bytes.constData(), static_cast(bytes.size())); } -QRect treeOverlayGeometry(const QWidget* viewport) { - const int height = std::clamp(static_cast(viewport->height() * kTreeOverlayHeightRatio), - kTreeOverlayMinHeight, - kTreeOverlayMaxHeight); - return QRect(kTreeOverlayMargin, kTreeOverlayMargin, kTreeOverlayWidth, height); -} - -std::vector -collectScalarUnion(const std::vector>& meshes) { - std::set names; - for (const auto& mesh : meshes) { - if (!mesh || !mesh->GetPointData()) { - continue; - } - vtkPointData* pointData = mesh->GetPointData(); - for (int arrayIndex = 0; arrayIndex < pointData->GetNumberOfArrays(); ++arrayIndex) { - vtkDataArray* arr = pointData->GetArray(arrayIndex); - if (!arr || !arr->GetName()) { - continue; - } - names.insert(arr->GetName()); - } - } - return std::vector(names.begin(), names.end()); -} - -QIcon partColorIcon(const std::array& rgb) { - constexpr int kSize = 12; - QPixmap pix(kSize, kSize); - pix.fill(Qt::transparent); - - const int r = std::clamp(static_cast(std::lround(rgb[0] * 255.0)), 0, 255); - const int g = std::clamp(static_cast(std::lround(rgb[1] * 255.0)), 0, 255); - const int b = std::clamp(static_cast(std::lround(rgb[2] * 255.0)), 0, 255); - - QPainter painter(&pix); - painter.setRenderHint(QPainter::Antialiasing, true); - painter.setPen(QPen(QColor(22, 22, 22, 220), 1.0)); - painter.setBrush(QColor(r, g, b)); - QPolygon poly; - poly << QPoint(2, kSize - 2) << QPoint(kSize / 2, 2) << QPoint(kSize - 2, kSize - 2); - painter.drawPolygon(poly); - return QIcon(pix); -} -} // namespace - -// ───────────────────────────────────────────────────────────────────── -// Event filter that keeps VTK interactions predictable: -// - swallow hover-only motion to avoid implicit rotate state, -// - route wheel zoom through a single camera-dolly path, -// - handle scalar cycling/quit hotkeys. -// ───────────────────────────────────────────────────────────────────── -class VtkMouseFilter : public QObject { -public: - explicit VtkMouseFilter(QWidget* vtkRoot, - QWidget* overlayColorBar, - QWidget* overlayTree, - std::function onSpaceCycle, - std::function onViewportResize, - QObject* parent = nullptr) - : QObject(parent), vtkRoot_(vtkRoot), overlayColorBar_(overlayColorBar), - overlayTree_(overlayTree), onSpaceCycle_(std::move(onSpaceCycle)), - onViewportResize_(std::move(onViewportResize)) {} - -protected: - bool eventFilter(QObject* watched, QEvent* event) override { - auto* widget = qobject_cast(watched); - QWidget* vtkRoot = vtkRoot_.data(); - if (!widget || !vtkRoot) { - return QObject::eventFilter(watched, event); - } - - const bool insideVtkWidget = (widget == vtkRoot || vtkRoot->isAncestorOf(widget)); - if (!insideVtkWidget) { - return QObject::eventFilter(watched, event); - } - - QWidget* overlayColorBar = overlayColorBar_.data(); - QWidget* overlayTree = overlayTree_.data(); - - bool insideOverlay = false; - for (QWidget* current = widget; current; current = current->parentWidget()) { - if ((overlayColorBar && current == overlayColorBar) || - qobject_cast(current)) { - insideOverlay = true; - break; - } - } - if (insideOverlay) { - return QObject::eventFilter(watched, event); - } - - switch (event->type()) { - case QEvent::Resize: - if (widget == vtkRoot) { - if (overlayColorBar) { - overlayColorBar->setGeometry(colorBarOverlayGeometry(vtkRoot)); - } - if (overlayTree) { - overlayTree->setGeometry(treeOverlayGeometry(vtkRoot)); - } - if (onViewportResize_) { - onViewportResize_(); - } - } - break; - case QEvent::MouseMove: { - auto* me = static_cast(event); - if (me->buttons() == Qt::NoButton) - return true; // swallow hover‐only moves - break; - } - case QEvent::Wheel: - if (widget != vtkRoot) { - return true; - } - if (auto* we = static_cast(event)) { - auto* vtkView = qobject_cast(vtkRoot); - if (!vtkView || !vtkView->renderWindow()) { - return true; - } - - double steps = 0.0; - if (!we->pixelDelta().isNull()) { - steps = static_cast(we->pixelDelta().y()) / 120.0; - } else { - steps = static_cast(we->angleDelta().y()) / 120.0; - } - if (std::abs(steps) < 1e-6) { - return true; - } - - auto* renderWindow = vtkView->renderWindow(); - auto* renderers = renderWindow->GetRenderers(); - if (!renderers) { - return true; - } - - vtkCollectionSimpleIterator cameraCookie; - renderers->InitTraversal(cameraCookie); - vtkRenderer* renderer = renderers->GetNextRenderer(cameraCookie); - if (!renderer || !renderer->GetActiveCamera()) { - return true; - } - - const double factor = std::pow(1.20, steps); - renderer->GetActiveCamera()->Dolly(factor); - renderer->ResetCameraClippingRange(); - renderWindow->Render(); - return true; - } - return true; - case QEvent::HoverMove: - case QEvent::NativeGesture: - case QEvent::Gesture: - case QEvent::TouchBegin: - case QEvent::TouchUpdate: - case QEvent::TouchEnd: - return true; // block trackpad rotate / pinch gestures - case QEvent::KeyPress: { - auto* ke = static_cast(event); - if (ke->key() == Qt::Key_Space && onSpaceCycle_) { - onSpaceCycle_(); - return true; - } - if (ke->key() == Qt::Key_Q) { - QApplication::quit(); - return true; - } - break; - } - case QEvent::ShortcutOverride: { - auto* ke = static_cast(event); - if (ke->key() == Qt::Key_Space || ke->key() == Qt::Key_Q) { - ke->accept(); - return true; - } - break; - } - default: - break; - } - return QObject::eventFilter(watched, event); - } - -private: - QPointer vtkRoot_; - QPointer overlayColorBar_; - QPointer overlayTree_; - std::function onSpaceCycle_; - std::function onViewportResize_; -}; - -// ───────────────────────────────────────────────────────────────────── struct Args { std::vector meshfiles; bool explode_view = false; + bool common_cat_lut = false; bool version = false; bool help = false; + std::string thumbnail_output; // non-empty → offscreen render to PNG and exit }; -Args parseArgs(int argc, char* argv[]) { +// requireFiles=false used on macOS where the file may arrive via QFileOpenEvent instead of argv. +Args parseArgs(int argc, char* argv[], bool requireFiles = true) { Args args; cxxopts::Options options("vv", "A Qt-based mesh viewer"); options.positional_help(" [ ...]"); options.add_options()( "e,explode", "Explode scalar view", cxxopts::value(args.explode_view))( + "C,common-cat-lut", + "Share one categorical colormap across all categorical scalars for cross-scalar comparison", + cxxopts::value(args.common_cat_lut))( "v,version", "Show version and exit", cxxopts::value(args.version))( "h,help", "Show help and exit", cxxopts::value(args.help))( + "T,thumbnail", + "Render offscreen thumbnail to PNG (macOS Quick Look)", + cxxopts::value(args.thumbnail_output))( "meshfiles", "Mesh files or '-'", cxxopts::value>(args.meshfiles)); options.parse_positional({"meshfiles"}); - options.parse(argc, argv); + try { + options.parse(argc, argv); + // cxxopts exceptions all derive from std::exception; catching the base type + // works across cxxopts versions (older ones lack the cxxopts::exceptions + // namespace) and keeps the Ubuntu/Docker system-cxxopts build green. + } catch (const std::exception& e) { + std::cerr << "vv: " << e.what() << "\n\n" << options.help() << '\n'; + std::exit(1); + } if (args.help) { std::cout << options.help() << '\n'; @@ -282,315 +81,181 @@ Args parseArgs(int argc, char* argv[]) { std::cout << "vv version " << VV_VERSION << " (built " << VV_BUILD_DATE << ")\n"; std::exit(0); } - if (args.meshfiles.empty()) { + if (args.meshfiles.empty() && requireFiles && args.thumbnail_output.empty()) { std::cerr << "Usage: vv [ ...]\n" << options.help() << '\n'; std::exit(1); } return args; } -// ═════════════════════════════════════════════════════════════════════ -int main(int argc, char* argv[]) { - Args args = parseArgs(argc, argv); - - if (args.meshfiles.size() > 1 && !args.explode_view) { - std::cerr << "Warning: Multiple mesh files provided without -e flag. " - "Using only the first file.\n"; +// Offscreen render of meshFile → PNG at outPath. Used by the macOS QLGenerator. +int renderThumbnail(const std::string& meshFile, const std::string& outPath) { + MeshLoadResult result = loadMeshes({meshFile}, false); + if (!result.ok || result.meshes.meshes.empty()) { + std::cerr << "vv --thumbnail: failed to load " << meshFile << "\n"; + return 1; } - MeshLoadResult loadResult = loadMeshes(args.meshfiles, args.explode_view); - if (!loadResult.ok) { - std::cerr << loadResult.error << std::endl; - return loadResult.exitCode; - } - - auto& allMeshes = loadResult.meshes.meshes; - auto& allNames = loadResult.meshes.names; - - std::vector> colorsHex; - colorsHex.reserve(allMeshes.size()); - for (size_t i = 0; i < allMeshes.size(); ++i) - colorsHex.push_back(generateDistinctColor(static_cast(i))); - - // ── Qt + VTK setup ──────────────────────────────────────────── - QSurfaceFormat format = QVTKOpenGLNativeWidget::defaultFormat(); - format.setSwapInterval(0); - format.setSamples(0); - QSurfaceFormat::setDefaultFormat(format); - QApplication app(argc, argv); - - QMainWindow window; - window.setWindowTitle("VV Qt mesh viewer"); - window.resize(1300, 980); - - auto* central = new QWidget(&window); - auto* layout = new QHBoxLayout(central); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); - - // Left pane — the VTK 3‑D view - auto* vtkWidget = new QVTKOpenGLNativeWidget(central); - vtkWidget->setFocusPolicy(Qt::StrongFocus); - vtkWidget->setAttribute(Qt::WA_AcceptTouchEvents, false); - layout->addWidget(vtkWidget, 1); - - window.setCentralWidget(central); - - // ── VTK render window ───────────────────────────────────────── - auto renderWindow = vtkSmartPointer::New(); - renderWindow->SetMultiSamples(0); - renderWindow->SetDesiredUpdateRate(120.0); - vtkWidget->setRenderWindow(renderWindow); + vtkNew renderer; + renderer->SetBackground(0.15, 0.15, 0.15); - MeshRenderer renderer; - renderer.setRenderContext(renderWindow, vtkWidget->interactor()); - - auto* colorBar = new ColorBarWidget(vtkWidget); - colorBar->setVisible(false); - colorBar->setAttribute(Qt::WA_TransparentForMouseEvents, false); - colorBar->setFocusPolicy(Qt::NoFocus); - colorBar->setGeometry(colorBarOverlayGeometry(vtkWidget)); - colorBar->raise(); - - auto* partsTree = new QTreeWidget(vtkWidget); - partsTree->setColumnCount(1); - partsTree->setHeaderHidden(true); - partsTree->setRootIsDecorated(true); - partsTree->setUniformRowHeights(true); - partsTree->setIndentation(18); - partsTree->setGeometry(treeOverlayGeometry(vtkWidget)); - partsTree->setVisible(false); - partsTree->setFocusPolicy(Qt::NoFocus); - partsTree->setHorizontalScrollMode(QAbstractItemView::ScrollPerPixel); - partsTree->setStyleSheet("QTreeWidget {" - " background: rgba(0,0,0,0);" - " color: #E2E2E2;" - " outline: none;" - " padding: 2px;" - "}"); - partsTree->raise(); - - std::vector scalarNames; - int activeScalarIdx = -1; - std::vector facetColorBars; - - auto layoutFacetColorBars = [&]() { - if (facetColorBars.empty()) { - return; - } - const int viewportWidth = vtkWidget->width(); - const int viewportHeight = vtkWidget->height(); - const int normalTargetHeight = - std::clamp(static_cast(viewportHeight * kOverlayHeightRatio), - kOverlayMinHeight, - kOverlayMaxHeight); - - for (size_t panelIndex = 0; panelIndex < facetColorBars.size(); ++panelIndex) { - FacetPanelInfo panelInfo; - if (!renderer.getFacetPanelInfo(panelIndex, panelInfo)) { - continue; - } - - const int panelX = static_cast(std::round(panelInfo.viewport[0] * viewportWidth)); - const int panelY = - static_cast(std::round((1.0 - panelInfo.viewport[3]) * viewportHeight)); - const int panelW = - std::max(1, - static_cast(std::round((panelInfo.viewport[2] - panelInfo.viewport[0]) * - viewportWidth))); - const int panelH = - std::max(1, - static_cast(std::round((panelInfo.viewport[3] - panelInfo.viewport[1]) * - viewportHeight))); - - const int margin = kFacetBarMargin; - const int barW = kFacetBarWidth; - int barH = normalTargetHeight; - barH = std::min(barH, std::max(40, panelH - 2 * margin)); - const int barX = panelX + std::max(0, panelW - barW - margin); - const int barY = panelY + std::max(1, (panelH - barH) / 2); - - ColorBarWidget* bar = facetColorBars[panelIndex]; - bar->setGeometry(barX, barY, barW, barH); - bar->raise(); - } - }; - - auto applyNoScalar = [&]() { - renderer.clearActiveScalar(); - colorBar->setVisible(false); - colorBar->setTitle("Geometry"); - activeScalarIdx = -1; - }; - - auto applyScalarAtIndex = [&](int index) { - if (index < 0 || index >= static_cast(scalarNames.size())) { - applyNoScalar(); - return; - } + for (auto& mesh : result.meshes.meshes) { + vtkNew mapper; + mapper->SetInputDataObject(mesh); + mapper->ScalarVisibilityOff(); + vtkNew actor; + actor->SetMapper(mapper); + renderer->AddActor(actor); + } + renderer->ResetCamera(); + + vtkNew window; + window->SetOffScreenRendering(1); + window->SetSize(1024, 768); + window->AddRenderer(renderer); + window->Render(); + + vtkNew w2i; + w2i->SetInput(window); + w2i->Update(); + + vtkNew writer; + writer->SetFileName(outPath.c_str()); + writer->SetInputConnection(w2i->GetOutputPort()); + writer->Write(); + return 0; +} - if (!renderer.setActiveScalar(scalarNames[index])) { - return; - } - activeScalarIdx = index; - colorBar->setTitle(QString::fromStdString(scalarNames[index])); +} // namespace - double globalRange[2] = {0.0, 1.0}; - if (!renderer.getActiveScalarGlobalRange(globalRange)) { - colorBar->setVisible(false); - return; - } - colorBar->setVisible(true); - colorBar->setRange(globalRange[0], globalRange[1]); - double clipRange[2] = {globalRange[0], globalRange[1]}; - renderer.getClipRange(clipRange); - colorBar->setClipRange(clipRange[0], clipRange[1]); - }; +#ifdef __APPLE__ +// Subclass to capture QFileOpenEvent (sent by macOS when user opens a file in Finder). +class VVApplication : public QApplication { +public: + using QApplication::QApplication; + QString fileToOpen; - auto cycleScalar = [&]() { - if (scalarNames.empty()) { - applyNoScalar(); - return; - } - if (activeScalarIdx < 0) { - applyScalarAtIndex(0); - return; - } - const int next = activeScalarIdx + 1; - if (next >= static_cast(scalarNames.size())) { - applyNoScalar(); - return; +protected: + bool event(QEvent* e) override { + if (e->type() == QEvent::FileOpen) { + fileToOpen = static_cast(e)->file(); + return true; } - applyScalarAtIndex(next); - }; - - app.installEventFilter( - new VtkMouseFilter(vtkWidget, colorBar, partsTree, cycleScalar, layoutFacetColorBars, &app)); - - QTimer::singleShot(0, [&]() { colorBar->setGeometry(colorBarOverlayGeometry(vtkWidget)); }); - - QTimer::singleShot(0, [&]() { partsTree->setGeometry(treeOverlayGeometry(vtkWidget)); }); - - const bool useFacetGrid = args.explode_view; - if (useFacetGrid) { - renderer.setupFacetGrid(allMeshes, allNames, colorsHex); - renderer.startFacetGrid(); - colorBar->setVisible(false); - partsTree->setVisible(false); - - const size_t panelCount = renderer.getFacetPanelCount(); - facetColorBars.reserve(panelCount); - for (size_t panelIndex = 0; panelIndex < panelCount; ++panelIndex) { - FacetPanelInfo panelInfo; - if (!renderer.getFacetPanelInfo(panelIndex, panelInfo)) { - continue; - } - - auto* panelBar = new ColorBarWidget(vtkWidget); - panelBar->setFocusPolicy(Qt::NoFocus); - panelBar->setTitle(QString::fromStdString(panelInfo.title)); - panelBar->setRange(panelInfo.globalRange[0], panelInfo.globalRange[1]); - panelBar->setClipRange(panelInfo.clipRange[0], panelInfo.clipRange[1]); - panelBar->setVisible(true); + return QApplication::event(e); + } +}; +#endif - QObject::connect(panelBar, - &ColorBarWidget::clipRangeChanged, - [&, panelIndex, panelBar](double lo, double hi) { - if (renderer.setFacetPanelClipRange(panelIndex, lo, hi)) { - FacetPanelInfo updated; - if (renderer.getFacetPanelInfo(panelIndex, updated)) { - panelBar->setClipRange(updated.clipRange[0], updated.clipRange[1]); - } - } - }); +#ifdef _WIN32 +namespace { +void addWindowsQtPluginPath() { + wchar_t buf[MAX_PATH]; + if (GetModuleFileNameW(nullptr, buf, MAX_PATH) == 0U) { + return; + } + QString exe = QString::fromWCharArray(buf); + const qsizetype sep = + std::max(exe.lastIndexOf(QLatin1Char('/')), exe.lastIndexOf(QLatin1Char('\\'))); + if (sep < 0) { + return; + } + QCoreApplication::addLibraryPath(exe.left(sep) + QStringLiteral("/plugins")); +} +} // namespace +#endif - facetColorBars.push_back(panelBar); +// ═════════════════════════════════════════════════════════════════════ +int main(int argc, char* argv[]) try { +#ifdef __APPLE__ + // Strip the macOS -psn_XXXX process serial number argument before option parsing. + std::vector filteredArgv; + for (int i = 0; i < argc; ++i) { + if (std::string(argv[i]).substr(0, 5) != "-psn_") { + filteredArgv.push_back(argv[i]); } + } + int filteredArgc = static_cast(filteredArgv.size()); + Args args = parseArgs(filteredArgc, filteredArgv.data(), /*requireFiles=*/false); +#else + Args args = parseArgs(argc, argv); +#endif - layoutFacetColorBars(); - QTimer::singleShot(0, [&]() { layoutFacetColorBars(); }); - } else { - renderer.setup(allMeshes, allNames, colorsHex); - renderer.start(); - - partsTree->clear(); - for (const MeshGroup& group : loadResult.meshes.groups) { - auto* groupItem = new QTreeWidgetItem(partsTree); - groupItem->setText(0, QString::fromStdString(group.name)); - groupItem->setFlags(groupItem->flags() | Qt::ItemIsUserCheckable); - groupItem->setCheckState(0, Qt::Checked); - - for (size_t partIndex : group.partIndices) { - if (partIndex >= loadResult.meshes.partNames.size()) { - continue; - } - auto* partItem = new QTreeWidgetItem(groupItem); - partItem->setText(0, QString::fromStdString(loadResult.meshes.partNames[partIndex])); - if (partIndex < colorsHex.size()) { - partItem->setIcon(0, partColorIcon(colorsHex[partIndex])); - } - partItem->setFlags(partItem->flags() | Qt::ItemIsUserCheckable); - partItem->setCheckState(0, Qt::Checked); - partItem->setData(0, Qt::UserRole, static_cast(partIndex)); - } - groupItem->setExpanded(group.partIndices.size() <= 8); + // --thumbnail mode: offscreen render, no GUI needed. + if (!args.thumbnail_output.empty()) { + if (args.meshfiles.empty()) { + std::cerr << "Usage: vv --thumbnail \n"; + return 1; } - partsTree->setVisible(!loadResult.meshes.groups.empty()); - - QObject::connect(partsTree, &QTreeWidget::itemChanged, [&](QTreeWidgetItem* item, int column) { - if (!item || column != 0) { - return; - } - - const bool checked = (item->checkState(0) == Qt::Checked); - QSignalBlocker block(partsTree); - - if (item->childCount() > 0) { - for (int childIndex = 0; childIndex < item->childCount(); ++childIndex) { - QTreeWidgetItem* child = item->child(childIndex); - child->setCheckState(0, checked ? Qt::Checked : Qt::Unchecked); - const size_t partIndex = static_cast(child->data(0, Qt::UserRole).toULongLong()); - renderer.setPartVisible(partIndex, checked); - } - return; - } + return renderThumbnail(args.meshfiles.front(), args.thumbnail_output); + } - const size_t partIndex = static_cast(item->data(0, Qt::UserRole).toULongLong()); - renderer.setPartVisible(partIndex, checked); + if (args.meshfiles.size() > 1 && !args.explode_view) { + std::cerr << "Warning: Multiple mesh files provided without -e flag. " + "Using only the first file.\n"; + } - QTreeWidgetItem* parent = item->parent(); - if (!parent) { - return; - } - int checkedChildren = 0; - for (int childIndex = 0; childIndex < parent->childCount(); ++childIndex) { - if (parent->child(childIndex)->checkState(0) == Qt::Checked) { - ++checkedChildren; - } - } - if (checkedChildren == 0) { - parent->setCheckState(0, Qt::Unchecked); - } else if (checkedChildren == parent->childCount()) { - parent->setCheckState(0, Qt::Checked); - } else { - parent->setCheckState(0, Qt::PartiallyChecked); - } - }); + // ── Qt + VTK setup ──────────────────────────────────────────── + QSurfaceFormat format = QVTKOpenGLNativeWidget::defaultFormat(); + format.setSwapInterval(0); + format.setSamples(0); + QSurfaceFormat::setDefaultFormat(format); +#ifdef _WIN32 + addWindowsQtPluginPath(); +#endif + +#ifdef __APPLE__ + VVApplication app(filteredArgc, filteredArgv.data()); + // Drain the Apple Event queue so QFileOpenEvent (Finder double-click) is delivered + // before we check for files. + QCoreApplication::processEvents(); + if (!app.fileToOpen.isEmpty() && args.meshfiles.empty()) { + args.meshfiles.push_back(QStringToUtf8(app.fileToOpen)); + } +#else + QApplication app(argc, argv); +#endif - // ── colorbar handles → renderer ──────────────────────────── - QObject::connect(colorBar, &ColorBarWidget::clipRangeChanged, [&](double lo, double hi) { - renderer.setClipRange(lo, hi); - }); + // No file from argv or Apple Event → file picker + explode option dialog. + if (args.meshfiles.empty()) { + QString path = QFileDialog::getOpenFileName( + nullptr, + "Open mesh file", + QString(), + "Mesh files (*.vtk *.vtp *.vtu *.vtkhdf *.ply *.k *.key *.json);;All files (*)"); + if (path.isEmpty()) + return 0; + + // Small options dialog after file selection. + QDialog opts; + opts.setWindowTitle("Open options"); + auto* vl = new QVBoxLayout(&opts); + auto* explodeCheck = new QCheckBox("Explode scalar view (-e)", &opts); + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &opts); + vl->addWidget(explodeCheck); + vl->addWidget(buttons); + QObject::connect(buttons, &QDialogButtonBox::accepted, &opts, &QDialog::accept); + QObject::connect(buttons, &QDialogButtonBox::rejected, &opts, &QDialog::reject); + if (opts.exec() != QDialog::Accepted) + return 0; + + args.meshfiles.push_back(QStringToUtf8(path)); + args.explode_view = explodeCheck->isChecked(); + } - scalarNames = collectScalarUnion(allMeshes); - if (!scalarNames.empty()) { - applyScalarAtIndex(0); - } else { - applyNoScalar(); - } + MeshLoadResult loadResult = loadMeshes(args.meshfiles, args.explode_view); + if (!loadResult.ok) { + std::cerr << loadResult.error << '\n'; + return loadResult.exitCode; } + ViewerOptions viewerOptions; + viewerOptions.explodeView = args.explode_view; + viewerOptions.commonCatLut = args.common_cat_lut; + + ViewerWindow window(std::move(loadResult), viewerOptions); window.show(); - vtkWidget->setFocus(); - return app.exec(); + return QApplication::exec(); +} catch (const std::exception& e) { + std::cerr << "vv: fatal: " << e.what() << '\n'; + return 1; } diff --git a/src/mesh_utils.cpp b/src/mesh_utils.cpp index e8de3c0..84d8439 100644 --- a/src/mesh_utils.cpp +++ b/src/mesh_utils.cpp @@ -10,13 +10,15 @@ #endif #include #include +#include #include +#include std::string readHeader(FILE* f, size_t nbytes) { if (!f) return {}; std::string s(nbytes, '\0'); - size_t n = fread(&s[0], 1, nbytes, f); + size_t n = fread(s.data(), 1, nbytes, f); s.resize(n); return s; } @@ -67,37 +69,42 @@ std::array generateDistinctColor(int i) { std::string stdinToTempFile() { std::string tmpPath; #ifdef _WIN32 - char tmpName[L_tmpnam + 1]; - errno_t err = tmpnam_s(tmpName, L_tmpnam); - if (err != 0) + // GetTempFileName creates the file atomically (no tmpnam race). + char tmpDir[MAX_PATH + 1]; + const DWORD dirLen = GetTempPathA(MAX_PATH, tmpDir); + if (dirLen == 0 || dirLen > MAX_PATH) + return {}; + char tmpName[MAX_PATH + 1]; + if (GetTempFileNameA(tmpDir, "vv", 0, tmpName) == 0) return {}; tmpPath = tmpName; FILE* out = fopen(tmpPath.c_str(), "wb"); - if (!out) + if (!out) { + remove(tmpPath.c_str()); return {}; + } #else - char tmpName[] = "/tmp/vvstdinXXXXXX"; - int fd = mkstemp(tmpName); + const char* tmpDir = getenv("TMPDIR"); + std::string tmplStr = std::string(tmpDir && *tmpDir ? tmpDir : "/tmp") + "/vvstdinXXXXXX"; + std::vector tmpl(tmplStr.begin(), tmplStr.end()); + tmpl.push_back('\0'); + int fd = mkstemp(tmpl.data()); if (fd == -1) return {}; FILE* out = fdopen(fd, "wb"); if (!out) { close(fd); - unlink(tmpName); + unlink(tmpl.data()); return {}; } - tmpPath = tmpName; + tmpPath = tmpl.data(); #endif char buf[8192]; size_t n; while ((n = fread(buf, 1, sizeof(buf), stdin)) > 0) { if (fwrite(buf, 1, n, out) != n) { fclose(out); -#ifdef _WIN32 remove(tmpPath.c_str()); -#else - unlink(tmpPath.c_str()); -#endif return {}; } } diff --git a/vcpkg.json b/vcpkg.json new file mode 100644 index 0000000..b84fdc7 --- /dev/null +++ b/vcpkg.json @@ -0,0 +1,13 @@ +{ + "name": "vtkviewer", + "version": "1.1.0", + "dependencies": [ + { + "name": "vtk", + "features": [ "qt", "opengl" ] + }, + "fmt", + "cxxopts", + "nlohmann-json" + ] +}