diff --git a/.github/workflows/conan.yml b/.github/workflows/conan.yml index be15e8e..1c85a4b 100644 --- a/.github/workflows/conan.yml +++ b/.github/workflows/conan.yml @@ -36,10 +36,9 @@ jobs: image: ubuntu-24.04 cc: gcc-15 cxx: g++-15 - - name: macOS (Clang) - image: macos-latest - cc: clang - cxx: clang++ + # macOS disabled: Apple Clang cannot build quiver (missing + # std::ranges::iota, consteval support, std::submdspan, + # std::execution::par). - name: Windows (MSVC) image: windows-latest cc: cl @@ -48,11 +47,17 @@ jobs: MESON_VERSION: "1.7.2" FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true CCACHE_DIR: ${{ github.workspace }}/.ccache + SUBPROJECT_PAT: ${{ secrets.SUBPROJECT_PAT }} steps: - name: Checkout code uses: actions/checkout@v6 + - name: Configure git for private subprojects + if: env.SUBPROJECT_PAT != '' + shell: bash + run: git config --global url."https://x-access-token:${SUBPROJECT_PAT}@github.com/".insteadOf "https://github.com/" + - name: Set up Python uses: actions/setup-python@v6 with: @@ -62,7 +67,13 @@ jobs: - name: Install GCC 15 (Linux) if: runner.os == 'Linux' run: | - sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test + # Retry PPA setup — Launchpad occasionally returns transient + # GPGKeyTemporarilyNotFoundError (HTTP 500) errors. + for i in 1 2 3 4 5; do + sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test && break + echo "Attempt $i failed, retrying in 10s..." + sleep 10 + done sudo apt-get update sudo apt-get install -y gcc-15 g++-15 ccache @@ -72,8 +83,9 @@ jobs: sudo apt-get install -y \ libvulkan-dev glslang-dev libshaderc-dev \ libglfw3-dev \ - qt6-base-dev libqt6opengl6-dev libqt6openglwidgets6-dev \ + qt6-base-dev \ libgl-dev libgl1-mesa-dev \ + libreadline-dev \ libx11-dev libx11-xcb-dev libfontenc-dev libice-dev libsm-dev \ libxau-dev libxaw7-dev libxcomposite-dev libxcursor-dev \ libxdamage-dev libxfixes-dev libxi-dev libxinerama-dev \ @@ -87,10 +99,6 @@ jobs: libxcb-present-dev libxcb-composite0-dev libxcb-ewmh-dev \ libxcb-res0-dev libxcb-util-dev - - name: Install ccache (macOS) - if: runner.os == 'macOS' - run: brew install ccache - - name: Setup MSVC (Windows) if: runner.os == 'Windows' uses: ilammy/msvc-dev-cmd@v1 @@ -100,7 +108,17 @@ jobs: - name: Install pkg-config (Windows) if: runner.os == 'Windows' - run: choco install pkgconfiglite + shell: pwsh + run: | + # Retry choco install — chocolatey community feed occasionally + # returns 504 Gateway Timeout errors. + foreach ($i in 1..5) { + choco install pkgconfiglite -y + if ($LASTEXITCODE -eq 0) { break } + Write-Host "Attempt $i failed, retrying in 10s..." + Start-Sleep -Seconds 10 + } + if ($LASTEXITCODE -ne 0) { exit 1 } # -- Conan -- - name: Create conan profile (Unix) @@ -123,7 +141,13 @@ jobs: conan profile show - name: Install conan dependencies - run: conan install . --output-folder=builddir/conan --build=missing + run: > + conan install . + --output-folder=builddir/conan + --build=missing + -o qt=False + -o visualization=False + -o protobuf=False # -- Caches -- - name: Cache ccache @@ -143,11 +167,26 @@ jobs: ccache -z # -- Build & Test -- + - name: Write compiler native file (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + mkdir -p builddir + { + echo "[binaries]" + echo "c = '${{ matrix.platform.cc }}'" + echo "cpp = '${{ matrix.platform.cxx }}'" + } > builddir/compiler.ini + - name: Configure env: CC: ${{ runner.os != 'Windows' && format('ccache {0}', matrix.platform.cc) || matrix.platform.cc }} CXX: ${{ runner.os != 'Windows' && format('ccache {0}', matrix.platform.cxx) || matrix.platform.cxx }} - run: meson setup builddir/ --native-file builddir/conan/conan_meson_native.ini + run: > + meson setup builddir/ + --wrap-mode=forcefallback + --native-file builddir/conan/conan_meson_native.ini + ${{ runner.os != 'Windows' && '--native-file builddir/compiler.ini' || '' }} - name: Build run: meson compile -C builddir/ @@ -156,6 +195,24 @@ jobs: if: runner.os != 'Windows' run: ccache -s + - name: Set conan library path (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + # Use pkg-config to resolve library directories from conan .pc files + # so shared libs (e.g. libtbb) are found at test runtime. + # The .pc files use ${prefix} variables that raw grep cannot resolve. + export PKG_CONFIG_PATH="builddir/conan:${PKG_CONFIG_PATH:-}" + CONAN_LIB_DIRS="" + for pc in builddir/conan/*.pc; do + pkg=$(basename "$pc" .pc) + dirs=$(pkg-config --libs-only-L "$pkg" 2>/dev/null | tr ' ' '\n' | sed 's/^-L//' | tr '\n' ':') + CONAN_LIB_DIRS="${CONAN_LIB_DIRS}${dirs}" + done + # Deduplicate + CONAN_LIB_DIRS=$(echo "$CONAN_LIB_DIRS" | tr ':' '\n' | sort -u | tr '\n' ':' | sed 's/:$//') + echo "LD_LIBRARY_PATH=${CONAN_LIB_DIRS}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" >> "$GITHUB_ENV" + - name: Test run: meson test -C builddir/ -v diff --git a/.github/workflows/system-packages.yml b/.github/workflows/system-packages.yml index e9bda38..8b5fc57 100644 --- a/.github/workflows/system-packages.yml +++ b/.github/workflows/system-packages.yml @@ -40,18 +40,23 @@ jobs: image: ubuntu-24.04 cc: gcc-15 cxx: g++-15 - - name: macOS (Clang) - image: macos-latest - cc: clang - cxx: clang++ + extra_args: "" + # macOS disabled: Apple Clang cannot build quiver (missing + # std::ranges::iota, consteval support, std::submdspan, + # std::execution::par). env: MESON_VERSION: "1.7.2" CCACHE_DIR: ${{ github.workspace }}/.ccache + SUBPROJECT_PAT: ${{ secrets.SUBPROJECT_PAT }} steps: - name: Checkout code uses: actions/checkout@v6 + - name: Configure git for private subprojects + if: env.SUBPROJECT_PAT != '' + run: git config --global url."https://x-access-token:${SUBPROJECT_PAT}@github.com/".insteadOf "https://github.com/" + - name: Set up Python uses: actions/setup-python@v6 with: @@ -59,17 +64,22 @@ jobs: # -- Toolchain & packages -- - name: Install packages (Linux) - if: runner.os == 'Linux' run: | - sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test + # Retry PPA setup — Launchpad occasionally returns transient + # GPGKeyTemporarilyNotFoundError (HTTP 500) errors. + for i in 1 2 3 4 5; do + sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test && break + echo "Attempt $i failed, retrying in 10s..." + sleep 10 + done sudo apt-get update sudo apt-get install -y gcc-15 g++-15 ccache sudo apt-get install -y \ - libfmt-dev libspdlog-dev libeigen3-dev \ - nlohmann-json3-dev catch2 \ + libeigen3-dev \ + nlohmann-json3-dev catch2 libtbb-dev libreadline-dev \ libvulkan-dev glslang-dev libshaderc-dev \ libglfw3-dev \ - qt6-base-dev libqt6opengl6-dev libqt6openglwidgets6-dev \ + qt6-base-dev \ libgl-dev libgl1-mesa-dev \ libx11-dev libx11-xcb-dev libfontenc-dev libice-dev libsm-dev \ libxau-dev libxaw7-dev libxcomposite-dev libxcursor-dev \ @@ -84,10 +94,6 @@ jobs: libxcb-present-dev libxcb-composite0-dev libxcb-ewmh-dev \ libxcb-res0-dev libxcb-util-dev - - name: Install packages (macOS) - if: runner.os == 'macOS' - run: brew install spdlog fmt catch2 nlohmann-json cli11 eigen ccache - - name: Install Meson and Ninja run: python -m pip install meson==${{ env.MESON_VERSION }} ninja @@ -120,7 +126,9 @@ jobs: run: > meson setup build/ --buildtype=${{ matrix.build_type }} + --wrap-mode=forcefallback -Dtesting=true + ${{ matrix.platform.extra_args }} - name: Build run: meson compile -C build/ diff --git a/.gitignore b/.gitignore index c9c9928..b992d82 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,20 @@ build* # meson wrap likes to add this file .meson-subproject-wrap-hash.txt + +# Ignore auto-promoted wrap-redirect files from subprojects. +# Only directly-needed wraps are whitelisted below. +subprojects/*.wrap +!subprojects/catch2.wrap +!subprojects/cli11.wrap +!subprojects/colormap_shaders.wrap +!subprojects/eigen.wrap +!subprojects/fmt.wrap +!subprojects/imgui.wrap +!subprojects/nlohmann_json.wrap +!subprojects/partio.wrap +!subprojects/quiver.wrap +!subprojects/spdlog.wrap +!subprojects/zipper.wrap + docs/ diff --git a/conanfile.py b/conanfile.py index 952fc1d..e3192b4 100644 --- a/conanfile.py +++ b/conanfile.py @@ -24,15 +24,17 @@ ("qt", [True, False], True, ["qt/6.8.3"]), ("protobuf", [True, False], True, ["protobuf/5.27.0"]), ("openvdb", [True, False], False, ["openvdb/11.0.0"]), - ("alembic", [True, False], True, ["alembic/1.8.6"]), + # Alembic's conan package does not produce a pkg-config file on Windows, + # so meson cannot find it. Disable by default until upstream is fixed. + ("alembic", [True, False], False, ["alembic/1.8.6"]), ("embree", [True, False], False, ["embree3/3.13.5"]), ("perfetto", [True, False], False, ["perfetto/50.1"]), ("pngpp", [True, False], False, ["pngpp/0.2.10"]), # Options with no Conan deps (handled entirely by Meson) ("json", [True, False], True, []), - ("partio", [True, False], True, []), - ("igl", [True, False], True, []), - ("eltopo", [True, False], True, []), + ("partio", [True, False], False, []), # cmake subproject; disabled in CI + ("igl", [True, False], False, []), # cmake subproject; disabled in CI + ("eltopo", [True, False], False, []), # cmake subproject; disabled in CI ] __OPTIONS__ = {name: values for name, values, default, deps in __OPTIONAL_FLAGS_WITH_DEPS__} @@ -57,10 +59,12 @@ def requirements(self): for dep in dependencies(self): self.requires(dep) + # Quiver (pulled as meson subproject) needs TBB for parallel execution. + self.requires("onetbb/2022.0.0") + # Pin transitive dependency versions to avoid conflicts. self.requires("fmt/11.0.2", override=True) self.requires("abseil/20240722.0", override=True) - self.requires("onetbb/2022.0.0", override=True) self.requires("boost/1.88.0", override=True) if self.options.visualization: self.requires("vulkan-headers/1.3.268.0", override=True) @@ -70,6 +74,8 @@ def requirements(self): self.requires("spirv-headers/1.3.268.0", override=True) def configure(self): + # onetbb requires hwloc as shared library + self.options["hwloc"].shared = True if self.options.visualization: self.options["glfw"].vulkan_static = True self.options["qt"].with_vulkan = True diff --git a/core/include/balsa/eigen/slice_filter.hpp b/core/include/balsa/eigen/slice_filter.hpp index 78f772a..fe2b454 100755 --- a/core/include/balsa/eigen/slice_filter.hpp +++ b/core/include/balsa/eigen/slice_filter.hpp @@ -1,4 +1,5 @@ -#pragma once +#if !defined(BALSA_EIGEN_SLICE_FILTER_HPP) +#define BALSA_EIGEN_SLICE_FILTER_HPP #include "balsa/eigen/types.hpp" @@ -45,3 +46,4 @@ auto slice_filter_col(const Eigen::MatrixBase &A, const VectorX & } }// namespace balsa::eigen +#endif diff --git a/core/include/balsa/eigen/stl2eigen.hpp b/core/include/balsa/eigen/stl2eigen.hpp index 087b831..5f60221 100644 --- a/core/include/balsa/eigen/stl2eigen.hpp +++ b/core/include/balsa/eigen/stl2eigen.hpp @@ -1,4 +1,5 @@ -#pragma once +#if !defined(BALSA_EIGEN_STL2EIGEN_HPP) +#define BALSA_EIGEN_STL2EIGEN_HPP #include "balsa/eigen/types.hpp" #include "balsa/eigen/container_size.hpp" #include @@ -73,3 +74,4 @@ auto stl2eigen(const Container &vec) { } }// namespace balsa::eigen +#endif diff --git a/core/include/balsa/zipper/stl2zipper.hpp b/core/include/balsa/zipper/stl2zipper.hpp index 7d5eeb1..f17f75a 100644 --- a/core/include/balsa/zipper/stl2zipper.hpp +++ b/core/include/balsa/zipper/stl2zipper.hpp @@ -1,4 +1,5 @@ -#pragma once +#if !defined(BALSA_ZIPPER_STL2ZIPPER_HPP) +#define BALSA_ZIPPER_STL2ZIPPER_HPP #include "types.hpp" #include @@ -13,3 +14,4 @@ auto stl2zipper(auto const &M) { } }// namespace balsa::zipper +#endif diff --git a/core/meson.build b/core/meson.build index e29614b..c83f8d0 100644 --- a/core/meson.build +++ b/core/meson.build @@ -40,6 +40,7 @@ endif core_lib = library('balsaCore', core_sources, include_directories: include_dir, dependencies: core_required_deps) core_dep = declare_dependency(link_with: core_lib, dependencies: core_required_deps, include_directories: include_dir) +meson.override_dependency('balsaCore', core_dep) if get_option('testing') subdir('tests') endif diff --git a/core/src/filesystem/prepend_to_filename.cpp b/core/src/filesystem/prepend_to_filename.cpp index 66e0d75..a2a23f5 100644 --- a/core/src/filesystem/prepend_to_filename.cpp +++ b/core/src/filesystem/prepend_to_filename.cpp @@ -5,6 +5,7 @@ namespace balsa::filesystem { std::filesystem::path prepend_to_filename(const std::filesystem::path &orig, const std::string &prefix) { auto parent = orig.parent_path(); auto filename = orig.filename(); - return parent / (prefix + std::string(filename)); + // std::filesystem::path is not implicitly convertible to std::string on MSVC + return parent / (prefix + filename.string()); } }// namespace balsa::filesystem diff --git a/core/src/logging/json_sink.cpp b/core/src/logging/json_sink.cpp index 7b82d27..cec39fe 100644 --- a/core/src/logging/json_sink.cpp +++ b/core/src/logging/json_sink.cpp @@ -14,7 +14,9 @@ void set_json_format(spdlog::logger &logger, bool messages_are_json) { } } std::shared_ptr make_json_file_logger(const std::string &name, const std::filesystem::path &path, bool messages_are_json) { - auto logger = spdlog::basic_logger_mt(name, path); + // spdlog::basic_logger_mt expects filename_t (std::wstring on Windows), + // so convert std::filesystem::path via .string() for portability. + auto logger = spdlog::basic_logger_mt(name, path.string()); set_json_format(*logger, messages_are_json); return logger; } diff --git a/core/tests/test_iterators.cpp b/core/tests/test_iterators.cpp index 3ab2bea..e047441 100644 --- a/core/tests/test_iterators.cpp +++ b/core/tests/test_iterators.cpp @@ -59,6 +59,7 @@ TEST_CASE("ranges heterogeneous line", "[ranges]") { for (auto &&num : nums) { std::cout << num << std::endl; } +#if defined(__cpp_lib_ranges_concat) std::array ret; auto inp = std::views::concat(nums, std::views::repeat(float(0))) | std::views::take(6); std::ranges::copy(inp, ret.begin()); @@ -66,4 +67,5 @@ TEST_CASE("ranges heterogeneous line", "[ranges]") { std::cout << v << " "; } std::cout << std::endl; +#endif } diff --git a/geometry/include/balsa/geometry/BoundingBox.hpp b/geometry/include/balsa/geometry/BoundingBox.hpp index a668dc2..346956e 100644 --- a/geometry/include/balsa/geometry/BoundingBox.hpp +++ b/geometry/include/balsa/geometry/BoundingBox.hpp @@ -1,21 +1,17 @@ -#if !defined(BALSA_GEOMETRY_BOUNDINGBOX) -#define BALSA_GEOMETRY_BOUNDINGBOX +#pragma once #if BALSA_HAS_QUIVER // ============================================================================ -// BoundingBox — thin wrapper around quiver::spatial::AABB +// BoundingBox — thin wrapper around quiver::spatial::AABB // ============================================================================ // -// BoundingBox is always double-precision, matching quiver's KDOP -// internals. It exposes a zipper-vector-based API (min(), max(), -// range(), corner(), expand(), contains()) on top of the underlying -// AABB. +// Provides a zipper-vector-based API (min(), max(), range(), corner(), +// expand(), contains()) on top of quiver's AABB. The scalar type T is +// explicit — no default — matching quiver's convention. // -// The old BoundingBox was templated on the scalar type; this -// version drops the T parameter (always double) but adds a conversion -// constructor from the AABB so that code can freely pass between the -// two representations. +// Per-axis accessors: min(axis), max(axis), range(axis). +// Convenience names: width(), height(), depth(). #include #include @@ -29,10 +25,11 @@ namespace balsa::geometry { -template<::zipper::rank_type Dim> +template class BoundingBox { public: - using aabb_type = quiver::spatial::AABB(Dim)>; + using value_type = T; + using aabb_type = quiver::spatial::AABB(Dim)>; // ── Construction ──────────────────────────────────────────────── @@ -40,26 +37,38 @@ class BoundingBox { BoundingBox() : m_aabb(aabb_type::empty()) {} /// From two zipper vectors (min corner, max corner). - template<::zipper::concepts::Vector MinVec, - ::zipper::concepts::Vector MaxVec> - BoundingBox(const MinVec &lo, const MaxVec &hi) : m_aabb(aabb_type::empty()) { - std::array lo_arr, hi_arr; + template <::zipper::concepts::Vector MinVec, + ::zipper::concepts::Vector MaxVec> + BoundingBox(const MinVec &lo, const MaxVec &hi) + : m_aabb(aabb_type::empty()) { + std::array lo_arr, hi_arr; for (::zipper::rank_type j = 0; j < Dim; ++j) { - lo_arr[j] = static_cast(lo(j)); - hi_arr[j] = static_cast(hi(j)); + lo_arr[j] = static_cast(lo(j)); + hi_arr[j] = static_cast(hi(j)); } - m_aabb.expand(std::span(lo_arr)); - m_aabb.expand(std::span(hi_arr)); + m_aabb.expand(std::span(lo_arr)); + m_aabb.expand(std::span(hi_arr)); } /// From a single point (degenerate box). - template<::zipper::concepts::Vector Vec> + template <::zipper::concepts::Vector Vec> explicit BoundingBox(const Vec &pt) : m_aabb(aabb_type::empty()) { - std::array arr; + std::array arr; for (::zipper::rank_type j = 0; j < Dim; ++j) { - arr[j] = static_cast(pt(j)); + arr[j] = static_cast(pt(j)); } - m_aabb.expand(std::span(arr)); + m_aabb.expand(std::span(arr)); + } + + /// From explicit min/max values (for integral types like uint32_t). + /// Only available when Dim == 2. + BoundingBox(T x_min, T y_min, T x_max, T y_max) + requires(Dim == 2) + : m_aabb(aabb_type::empty()) { + std::array lo_arr{x_min, y_min}; + std::array hi_arr{x_max, y_max}; + m_aabb.expand(std::span(lo_arr)); + m_aabb.expand(std::span(hi_arr)); } /// From an existing AABB (implicit conversion). @@ -67,46 +76,76 @@ class BoundingBox { BoundingBox(BoundingBox &&) = default; BoundingBox(const BoundingBox &) = default; - BoundingBox &operator=(BoundingBox &&) = default; - BoundingBox &operator=(const BoundingBox &) = default; + auto operator=(BoundingBox &&) -> BoundingBox & = default; + auto operator=(const BoundingBox &) -> BoundingBox & = default; // ── Access to underlying AABB ─────────────────────────────────── - const aabb_type &aabb() const { return m_aabb; } - aabb_type &aabb() { return m_aabb; } + auto aabb() const -> const aabb_type & { return m_aabb; } + auto aabb() -> aabb_type & { return m_aabb; } - // ── min / max (return zipper vectors) ─────────────────────────── + // ── min / max (full vector) ───────────────────────────────────── - auto min() const -> ::zipper::Vector { - ::zipper::Vector v; + auto min() const -> ::zipper::Vector { + ::zipper::Vector v; for (::zipper::rank_type j = 0; j < Dim; ++j) { v(j) = m_aabb.min(static_cast(j)); } return v; } - auto max() const -> ::zipper::Vector { - ::zipper::Vector v; + auto max() const -> ::zipper::Vector { + ::zipper::Vector v; for (::zipper::rank_type j = 0; j < Dim; ++j) { v(j) = m_aabb.max(static_cast(j)); } return v; } - // ── range (max - min) ─────────────────────────────────────────── + // ── Per-axis accessors ────────────────────────────────────────── + + auto min(::zipper::rank_type axis) const -> T { + return m_aabb.min(static_cast(axis)); + } + + auto max(::zipper::rank_type axis) const -> T { + return m_aabb.max(static_cast(axis)); + } - auto range() const -> ::zipper::Vector { - ::zipper::Vector v; + auto range(::zipper::rank_type axis) const -> T { + return m_aabb.width(static_cast(axis)); + } + + // ── range (full vector) ───────────────────────────────────────── + + auto range() const -> ::zipper::Vector { + ::zipper::Vector v; for (::zipper::rank_type j = 0; j < Dim; ++j) { v(j) = m_aabb.width(static_cast(j)); } return v; } + // ── Convenience dimension names ───────────────────────────────── + + auto width() const -> T { return range(0); } + + auto height() const -> T + requires(Dim >= 2) + { + return range(1); + } + + auto depth() const -> T + requires(Dim >= 3) + { + return range(2); + } + // ── corner ────────────────────────────────────────────────────── - auto corner(const std::bitset &c) const -> ::zipper::Vector { - ::zipper::Vector v; + auto corner(const std::bitset &c) const -> ::zipper::Vector { + ::zipper::Vector v; for (::zipper::rank_type j = 0; j < Dim; ++j) { v(j) = c[j] ? m_aabb.max(static_cast(j)) : m_aabb.min(static_cast(j)); @@ -116,34 +155,31 @@ class BoundingBox { // ── expand ────────────────────────────────────────────────────── - template<::zipper::concepts::Vector Vec> - void expand(const Vec &pt) { - std::array arr; + template <::zipper::concepts::Vector Vec> + auto expand(const Vec &pt) -> void { + std::array arr; for (::zipper::rank_type j = 0; j < Dim; ++j) { - arr[j] = static_cast(pt(j)); + arr[j] = static_cast(pt(j)); } - m_aabb.expand(std::span(arr)); + m_aabb.expand(std::span(arr)); } - void expand(const BoundingBox &other) { + auto expand(const BoundingBox &other) -> void { m_aabb.merge_in_place(other.m_aabb); } // ── contains ──────────────────────────────────────────────────── - template<::zipper::concepts::Vector Vec> - bool contains(const Vec &pt) const { - std::array arr; + template <::zipper::concepts::Vector Vec> + auto contains(const Vec &pt) const -> bool { + std::array arr; for (::zipper::rank_type j = 0; j < Dim; ++j) { - arr[j] = static_cast(pt(j)); + arr[j] = static_cast(pt(j)); } - return m_aabb.contains(std::span(arr)); + return m_aabb.contains(std::span(arr)); } - bool contains(const BoundingBox &other) const { - // A contains B iff merging B into A doesn't change A. - // Equivalently: A.min(j) <= B.min(j) && A.max(j) >= B.max(j) - // for all j. + auto contains(const BoundingBox &other) const -> bool { for (::zipper::rank_type j = 0; j < Dim; ++j) { auto axis = static_cast(j); if (m_aabb.min(axis) > other.m_aabb.min(axis)) return false; @@ -154,7 +190,7 @@ class BoundingBox { // ── is_empty ──────────────────────────────────────────────────── - bool is_empty() const { return m_aabb.is_empty(); } + auto is_empty() const -> bool { return m_aabb.is_empty(); } private: aabb_type m_aabb; @@ -162,105 +198,131 @@ class BoundingBox { // ── CTAD deduction guides ─────────────────────────────────────────── -template<::zipper::concepts::Vector MinVec, - ::zipper::concepts::Vector MaxVec> - requires(std::is_same_v) +template <::zipper::concepts::Vector MinVec, ::zipper::concepts::Vector MaxVec> + requires(std::is_same_v) BoundingBox(const MinVec &, const MaxVec &) - -> BoundingBox; + -> BoundingBox; -template<::zipper::concepts::Vector Vec> -BoundingBox(const Vec &) -> BoundingBox; +template <::zipper::concepts::Vector Vec> +BoundingBox(const Vec &) -> BoundingBox; -}// namespace balsa::geometry +} // namespace balsa::geometry -#else// !BALSA_HAS_QUIVER — legacy zipper-only implementation +#else // !BALSA_HAS_QUIVER — legacy zipper-only implementation -#include -#include #include +#include +#include #include -#include #include -#include - +#include +#include namespace balsa::geometry { -template +template class BoundingBox { private: using limits = std::numeric_limits; public: - template< - ::zipper::concepts::Vector MinVec, - ::zipper::concepts::Vector MaxVec> + using value_type = T; + + template <::zipper::concepts::Vector MinVec, + ::zipper::concepts::Vector MaxVec> BoundingBox(const MinVec &m, const MaxVec &M); - template< - ::zipper::concepts::Vector Vec> + template <::zipper::concepts::Vector Vec> BoundingBox(const Vec &m); BoundingBox() = default; BoundingBox(BoundingBox &&) = default; BoundingBox(const BoundingBox &) = default; - BoundingBox &operator=(BoundingBox &&) = default; - BoundingBox &operator=(const BoundingBox &) = default; - - BoundingBox(zipper::index_type dim) : - - m_min(::zipper::expression::nullary::Constant(::zipper::create_dextents(dim), limits::max())), - m_max(::zipper::expression::nullary::Constant(::zipper::create_dextents(dim), limits::lowest())) {} - - template<::zipper::concepts::Vector Vec> - void expand(const Vec &a); - void expand(const BoundingBox &a); - - template<::zipper::concepts::Vector Vec> - bool contains(const Vec &a) const; - bool contains(const BoundingBox &a) const; + auto operator=(BoundingBox &&) -> BoundingBox & = default; + auto operator=(const BoundingBox &) -> BoundingBox & = default; + + BoundingBox(zipper::index_type dim) + : + + m_min(::zipper::expression::nullary::Constant( + ::zipper::create_dextents(dim), + limits::max())), + m_max(::zipper::expression::nullary::Constant( + ::zipper::create_dextents(dim), + limits::lowest())) {} + + template <::zipper::concepts::Vector Vec> + auto expand(const Vec &a) -> void; + auto expand(const BoundingBox &a) -> void; + + template <::zipper::concepts::Vector Vec> + auto contains(const Vec &a) const -> bool; + auto contains(const BoundingBox &a) const -> bool; + + auto range() const { return m_max - m_min; } + + // Per-axis accessors. + auto min(::zipper::rank_type axis) const -> T { return m_min(axis); } + auto max(::zipper::rank_type axis) const -> T { return m_max(axis); } + auto range(::zipper::rank_type axis) const -> T { + return m_max(axis) - m_min(axis); + } - auto range() const { - return m_max - m_min; + // Convenience dimension names. + auto width() const -> T { return range(0); } + auto height() const -> T + requires(Dim >= 2) + { + return range(1); + } + auto depth() const -> T + requires(Dim >= 3) + { + return range(2); } - zipper::Vector corner(const std::bitset &c) const; + auto corner(const std::bitset &c) const -> zipper::Vector; - const auto &min() const { return m_min; } - const auto &max() const { return m_max; } + auto min() const -> const auto & { return m_min; } + auto max() const -> const auto & { return m_max; } private: - ::zipper::Vector m_min = ::zipper::expression::nullary::Constant(limits::max()); - ::zipper::Vector m_max = ::zipper::expression::nullary::Constant(limits::lowest()); + ::zipper::Vector m_min = + ::zipper::expression::nullary::Constant(limits::max()); + ::zipper::Vector m_max = + ::zipper::expression::nullary::Constant(limits::lowest()); }; - // make sure the types are teh same and the extents are valid -template< - ::zipper::concepts::Vector MinVec, - ::zipper::concepts::Vector MaxVec> - requires(std::is_same_v - && (MinVec::extents_type::static_extent(0) == std::dynamic_extent - || MinVec::extents_type::static_extent(0) == std::dynamic_extent || MaxVec::extents_type::static_extent(0) == MinVec::extents_type::static_extent(0))) -BoundingBox(const MinVec &m, const MaxVec &M) -> BoundingBox; - - -template< - ::zipper::concepts::Vector Vec> +template <::zipper::concepts::Vector MinVec, ::zipper::concepts::Vector MaxVec> + requires( + std::is_same_v + && (MinVec::extents_type::static_extent(0) == std::dynamic_extent + || MinVec::extents_type::static_extent(0) == std::dynamic_extent + || MaxVec::extents_type::static_extent(0) + == MinVec::extents_type::static_extent(0))) +BoundingBox(const MinVec &m, const MaxVec &M) + -> BoundingBox; + +template <::zipper::concepts::Vector Vec> BoundingBox(const Vec &) -> BoundingBox; - -template -template< - ::zipper::concepts::Vector MinVec, - ::zipper::concepts::Vector MaxVec> -BoundingBox::BoundingBox(const MinVec &m, const MaxVec &M) : m_min(m), m_max(M) { - - constexpr bool min_ext = MinVec::extents_type::static_extent(0) == std::dynamic_extent; - constexpr bool max_ext = MaxVec::extents_type::static_extent(0) == std::dynamic_extent; +template +template <::zipper::concepts::Vector MinVec, ::zipper::concepts::Vector MaxVec> +BoundingBox::BoundingBox(const MinVec &m, const MaxVec &M) + : m_min(m), m_max(M) { + constexpr bool min_ext = + MinVec::extents_type::static_extent(0) == std::dynamic_extent; + constexpr bool max_ext = + MaxVec::extents_type::static_extent(0) == std::dynamic_extent; if constexpr (min_ext && max_ext) { assert(m.extent(0) == M.extent(0)); } else if constexpr (min_ext) { @@ -270,15 +332,14 @@ BoundingBox::BoundingBox(const MinVec &m, const MaxVec &M) : m_min(m), m } } -template -template< - ::zipper::concepts::Vector Vec> -BoundingBox::BoundingBox(const Vec &m) : m_min(m), m_max(m) { -} +template +template <::zipper::concepts::Vector Vec> +BoundingBox::BoundingBox(const Vec &m) : m_min(m), m_max(m) {} -template +template -auto BoundingBox::corner(const std::bitset &c) const -> zipper::Vector { +auto BoundingBox::corner(const std::bitset &c) const + -> zipper::Vector { zipper::Vector D; for (zipper::rank_type j = 0; j < Dim; ++j) { D(j) = c[j] ? max()(j) : min()(j); @@ -286,35 +347,33 @@ auto BoundingBox::corner(const std::bitset &c) const -> zipper::Vec return D; } -template -template<::zipper::concepts::Vector Vec> -void BoundingBox::expand(const Vec &a) { - +template +template <::zipper::concepts::Vector Vec> +auto BoundingBox::expand(const Vec &a) -> void { m_min = ::zipper::as_vector(::zipper::min(a.as_array(), m_min.as_array())); m_max = ::zipper::as_vector(::zipper::max(a.as_array(), m_max.as_array())); } -template -void BoundingBox::expand(const BoundingBox &a) { - - m_min = ::zipper::as_vector(::zipper::min(a.m_min.as_array(), m_min.as_array())); - m_max = ::zipper::as_vector(::zipper::max(a.m_max.as_array(), m_max.as_array())); +template +auto BoundingBox::expand(const BoundingBox &a) -> void { + m_min = ::zipper::as_vector( + ::zipper::min(a.m_min.as_array(), m_min.as_array())); + m_max = ::zipper::as_vector( + ::zipper::max(a.m_max.as_array(), m_max.as_array())); } -template -template<::zipper::concepts::Vector Vec> -bool BoundingBox::contains(const Vec &x) const { - +template +template <::zipper::concepts::Vector Vec> +auto BoundingBox::contains(const Vec &x) const -> bool { auto xa = x.array(); return ((m_min.array() <= xa) && (m_max.array() >= xa)).all(); } -template -bool BoundingBox::contains(const BoundingBox &o) const { - - return ((m_min.array() <= o.m_min.array()) && (m_max.array() >= o.m_max.array())).all(); +template +auto BoundingBox::contains(const BoundingBox &o) const -> bool { + return ((m_min.array() <= o.m_min.array()) + && (m_max.array() >= o.m_max.array())) + .all(); } -}// namespace balsa::geometry - -#endif// BALSA_HAS_QUIVER +} // namespace balsa::geometry -#endif +#endif // BALSA_HAS_QUIVER diff --git a/geometry/include/balsa/geometry/bounding_box.hpp b/geometry/include/balsa/geometry/bounding_box.hpp index 09b9db7..7d2605b 100644 --- a/geometry/include/balsa/geometry/bounding_box.hpp +++ b/geometry/include/balsa/geometry/bounding_box.hpp @@ -1,49 +1,34 @@ #if !defined(BALSA_GEOMETRY_BOUNDING_BOX) #define BALSA_GEOMETRY_BOUNDING_BOX -#include -#include "balsa/eigen/types.hpp" +#include "BoundingBox.hpp" #include "balsa/eigen/concepts/shape_types.hpp" +#include "balsa/eigen/types.hpp" +#include #include -#include "BoundingBox.hpp" - namespace balsa::geometry { - -template +template auto bounding_box(const VType &V) { - using BBox = Eigen::AlignedBox; + using BBox = + Eigen::AlignedBox; if (V.cols() > 0) { - return BBox{ V.rowwise().minCoeff(), V.rowwise().maxCoeff() }; + return BBox{V.rowwise().minCoeff(), V.rowwise().maxCoeff()}; } return BBox{}; } -#if BALSA_HAS_QUIVER - -template<::zipper::concepts::Matrix VType> +template <::zipper::concepts::Matrix VType> auto bounding_box(const VType &V) { + using T = typename VType::value_type; constexpr auto Dim = VType::extents_type::static_extent(0); - BoundingBox bb; + BoundingBox bb; for (zipper::index_type j = 0; j < V.extent(1); ++j) { bb.expand(V.col(j)); } return bb; } -#else - -template<::zipper::concepts::Matrix VType> -auto bounding_box(const VType &V) { - BoundingBox bb; - for (zipper::index_type j = 0; j < V.extent(1); ++j) { - bb.expand(V.col(j)); - } - return bb; -} - -#endif - -}// namespace balsa::geometry +} // namespace balsa::geometry #endif diff --git a/geometry/include/balsa/geometry/point_cloud/bridson_poisson_disk_sampling.hpp b/geometry/include/balsa/geometry/point_cloud/bridson_poisson_disk_sampling.hpp index ae38be6..23b1ee4 100755 --- a/geometry/include/balsa/geometry/point_cloud/bridson_poisson_disk_sampling.hpp +++ b/geometry/include/balsa/geometry/point_cloud/bridson_poisson_disk_sampling.hpp @@ -1,4 +1,5 @@ -#pragma once +#if !defined(BALSA_GEOMETRY_POINT_CLOUD_BRIDSON_POISSON_DISK_SAMPLING_HPP) +#define BALSA_GEOMETRY_POINT_CLOUD_BRIDSON_POISSON_DISK_SAMPLING_HPP // Bridson's Poisson Disk Sampling // // NOTE: This file is a legacy stub from the mtao:: codebase. It depends on @@ -25,3 +26,4 @@ namespace balsa::geometry::point_cloud { // Requires: balsa::geometry::grid infrastructure (not yet ported) }// namespace balsa::geometry::point_cloud +#endif diff --git a/geometry/include/balsa/geometry/point_cloud/vdb_particle_list.hpp b/geometry/include/balsa/geometry/point_cloud/vdb_particle_list.hpp index f738e06..4833679 100644 --- a/geometry/include/balsa/geometry/point_cloud/vdb_particle_list.hpp +++ b/geometry/include/balsa/geometry/point_cloud/vdb_particle_list.hpp @@ -1,4 +1,5 @@ -#pragma once +#if !defined(BALSA_GEOMETRY_POINT_CLOUD_VDB_PARTICLE_LIST_HPP) +#define BALSA_GEOMETRY_POINT_CLOUD_VDB_PARTICLE_LIST_HPP #include "balsa/zipper/types.hpp" #include @@ -56,3 +57,4 @@ struct VDBParticleList_PosRadVec : public VDBParticleList_PosRad { }; }// namespace balsa::geometry::point_cloud +#endif diff --git a/geometry/include/balsa/geometry/point_cloud/vdb_points.hpp b/geometry/include/balsa/geometry/point_cloud/vdb_points.hpp index 1fe9c81..851713b 100644 --- a/geometry/include/balsa/geometry/point_cloud/vdb_points.hpp +++ b/geometry/include/balsa/geometry/point_cloud/vdb_points.hpp @@ -1,4 +1,5 @@ -#pragma once +#if !defined(BALSA_GEOMETRY_POINT_CLOUD_VDB_POINTS_HPP) +#define BALSA_GEOMETRY_POINT_CLOUD_VDB_POINTS_HPP // Lightweight zipper <-> OpenVDB PointDataGrid conversion utilities. // // Read/write particle attributes (positions, velocities, radii, etc.) @@ -301,3 +302,4 @@ inline std::vector> list_vdb_grids( } }// namespace balsa::geometry::point_cloud::vdb +#endif diff --git a/geometry/include/balsa/geometry/polygon_mesh/triangulate_polygons.hpp b/geometry/include/balsa/geometry/polygon_mesh/triangulate_polygons.hpp index 7ba3a5f..8c39f69 100644 --- a/geometry/include/balsa/geometry/polygon_mesh/triangulate_polygons.hpp +++ b/geometry/include/balsa/geometry/polygon_mesh/triangulate_polygons.hpp @@ -1,4 +1,5 @@ -#pragma once +#if !defined(BALSA_GEOMETRY_POLYGON_MESH_TRIANGULATE_POLYGONS_HPP) +#define BALSA_GEOMETRY_POLYGON_MESH_TRIANGULATE_POLYGONS_HPP #include "balsa/geometry/polygon_mesh/PolygonMesh.hpp" #include "balsa/geometry/triangle_mesh/earclipping.hpp" @@ -75,3 +76,4 @@ ColVectors triangulate_polygons(const polygon_mesh::PolygonMesh #include #include @@ -16,3 +17,4 @@ void check_circumcenter_squared(::zipper::concepts::Matrix auto const &V, const } } }// namespace +#endif diff --git a/meson.build b/meson.build index f500302..b634119 100644 --- a/meson.build +++ b/meson.build @@ -1,16 +1,18 @@ project('balsa', 'cpp', version : '0.1', - default_options : ['warning_level=3', 'cpp_std=c++26']) + default_options : ['warning_level=3', 'cpp_std=c++26,vc++latest']) cc = meson.get_compiler('cpp') -dl_lib = cc.find_library('dl') +dl_lib = cc.find_library('dl', required: false) # ── Core dependencies (system or WrapDB subproject fallback) ────────────── -spdlog_dep = dependency('spdlog', version: '>=1.9.2', default_options: ['tests=disabled']) +# Force spdlog to static when building as subproject: spdlog's wrapdb meson.build +# leaks -DFMT_EXPORT into interface compile_args when built as shared with std::format, +# which breaks fmt's own headers for any target that also depends on fmt directly. +spdlog_dep = dependency('spdlog', version: '>=1.9.2', default_options: ['tests=disabled', 'default_library=static']) eigen_dep = dependency('eigen3', version: '>=3.4.0') -zipper_proj = subproject('zipper', default_options: {'testing': false}) -zipper_dep = zipper_proj.get_variable('zipper_dep') +zipper_dep = dependency('zipper', default_options: ['testing=false']) core_required_deps = [spdlog_dep, eigen_dep, zipper_dep] @@ -59,8 +61,7 @@ endif subdir('core') if get_option('quiver') - quiver_proj = subproject('quiver', default_options: ['testing=false', 'tools=false', 'examples=false']) - quiver_dep = quiver_proj.get_variable('quiver_dep') + quiver_dep = dependency('quiver', default_options: ['testing=false', 'tools=false', 'examples=false']) endif subdir('geometry') diff --git a/meson_options.txt b/meson_options.txt index 9c10215..c71d56d 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -1,15 +1,12 @@ option('testing', type: 'boolean', value: true, description: 'enable unit testing') option('examples', type: 'boolean', value: true, description: 'enable example targets') -option('eltopo', type : 'boolean', value : false, description : 'Should we build the el topo submodule') option('embree', type: 'boolean', value: false, description: 'Use embree') option('openvdb', type : 'boolean', value : false, description : 'Build openvdb stuff') -option('pngpp', type : 'boolean', value : false, description : 'Use PNG++ for screenshots') option('json', type : 'boolean', value : true, description : 'Use json') option('protobuf', type : 'boolean', value : false, description : 'Use protobuf') option('partio', type : 'boolean', value : false, description : 'Use partio') option('imgui', type : 'boolean', value : true, description : 'Use imgui') -option('igl', type : 'boolean', value : false, description : 'Use libigl') option('alembic', type : 'boolean', value : false, description : 'Use alembic') option('perfetto', type : 'boolean', value : false, description : 'Use perfetto') diff --git a/subprojects/colormap_shaders.wrap b/subprojects/colormap_shaders.wrap index ff0e069..ce5cf57 100644 --- a/subprojects/colormap_shaders.wrap +++ b/subprojects/colormap_shaders.wrap @@ -3,4 +3,4 @@ url = https://github.com/mtao/colormap-shaders.git revision = 142a208adf287335d53d368aa5dea6ec708a8aef [provide] -dependency_names = colormap_shaders +colormap_shaders = colormap_shaders_dep diff --git a/subprojects/fmt.wrap b/subprojects/fmt.wrap index 7e9c5c4..9d93ef9 100644 --- a/subprojects/fmt.wrap +++ b/subprojects/fmt.wrap @@ -6,6 +6,7 @@ source_hash = aa3e8fbb6a0066c03454434add1f1fc23299e85758ceec0d7d2d974431481e40 source_fallback_url = https://github.com/mesonbuild/wrapdb/releases/download/fmt_12.0.0-1/fmt-12.0.0.tar.gz patch_filename = fmt_12.0.0-1_patch.zip patch_url = https://wrapdb.mesonbuild.com/v2/fmt_12.0.0-1/get_patch +patch_fallback_url = https://github.com/mesonbuild/wrapdb/releases/download/fmt_12.0.0-1/fmt_12.0.0-1_patch.zip patch_hash = 307f288ebf3850abf2f0c50ac1fb07de97df9538d39146d802f3c0d6cada8998 wrapdb_version = 12.0.0-1 diff --git a/subprojects/quiver.wrap b/subprojects/quiver.wrap index a6c3a2b..cc8ab97 100644 --- a/subprojects/quiver.wrap +++ b/subprojects/quiver.wrap @@ -1,3 +1,6 @@ [wrap-git] -url = git@github.com:mtao/quiver.git -revision = main +url = https://github.com/mtao/quiver.git +revision = feature/kdop-scalar-type + +[provide] +quiver = quiver_dep diff --git a/subprojects/zipper.wrap b/subprojects/zipper.wrap index 434c1a2..4b6e47d 100644 --- a/subprojects/zipper.wrap +++ b/subprojects/zipper.wrap @@ -3,3 +3,6 @@ url = https://github.com/mtao/zipper.git # dogfooding, so always fetching the current version revision = main #revision = e908334fd800e844fe6acc4150727901b557c6cb + +[provide] +zipper = zipper_dep diff --git a/visualization/include/balsa/scene_graph/BVHData.hpp b/visualization/include/balsa/scene_graph/BVHData.hpp index 112159c..904cda2 100644 --- a/visualization/include/balsa/scene_graph/BVHData.hpp +++ b/visualization/include/balsa/scene_graph/BVHData.hpp @@ -34,7 +34,7 @@ namespace balsa::scene_graph { // render pass; the viewer calls apply_pending_update() before the // next render pass to rebuild BVH / update wireframe child. -class MeshData;// forward +class MeshData; // forward class BVHData : public AbstractFeature { public: @@ -46,7 +46,7 @@ class BVHData : public AbstractFeature { // BVH build strategy. quiver::spatial::BVHBuildStrategy strategy = - quiver::spatial::BVHBuildStrategy::sah; + quiver::spatial::BVHBuildStrategy::sah; // Maximum number of primitives per leaf node. uint16_t max_leaf_size = 4; @@ -55,7 +55,7 @@ class BVHData : public AbstractFeature { int display_depth = 0; // Overlay wireframe color (RGBA). - float color[4] = { 0.1f, 0.8f, 0.2f, 1.0f }; + float color[4] = {0.1f, 0.8f, 0.2f, 1.0f}; // Whether the BVH overlay is enabled (visible). bool enabled = true; @@ -113,9 +113,9 @@ class BVHData : public AbstractFeature { private: // ── BVH storage (one per K variant) ───────────────────────────── - quiver::spatial::BVH<3, 3> _bvh_3; - quiver::spatial::BVH<3, 9> _bvh_9; - quiver::spatial::BVH<3, 13> _bvh_13; + quiver::spatial::BVH _bvh_3; + quiver::spatial::BVH _bvh_9; + quiver::spatial::BVH _bvh_13; int _bvh_height = -1; @@ -132,6 +132,6 @@ class BVHData : public AbstractFeature { void update_overlay(); }; -}// namespace balsa::scene_graph +} // namespace balsa::scene_graph #endif diff --git a/visualization/include/balsa/scene_graph/ImageData.hpp b/visualization/include/balsa/scene_graph/ImageData.hpp new file mode 100644 index 0000000..f13edf4 --- /dev/null +++ b/visualization/include/balsa/scene_graph/ImageData.hpp @@ -0,0 +1,124 @@ +#if !defined(BALSA_SCENE_GRAPH_IMAGE_DATA_HPP) +#define BALSA_SCENE_GRAPH_IMAGE_DATA_HPP + +#include +#include +#include +#include +#include + +#include "balsa/geometry/BoundingBox.hpp" +#include "balsa/scene_graph/AbstractFeature.hpp" +#include "balsa/visualization/vulkan/texture.hpp" + +namespace balsa::scene_graph { + +// ── ImageData ─────────────────────────────────────────────────────── +// +// Scene graph feature that holds CPU-side image pixel data. Analogous +// to MeshData but for 2D images. Owns the pixel buffer and tracks +// modifications via a version counter + dirty region so that the +// corresponding VulkanImageDrawable can sync only what changed. +// +// Supports two pixel formats: +// - RGBA8: 4 bytes/pixel (8-bit unsigned, sRGB) +// - RGBAF32: 16 bytes/pixel (32-bit float, linear HDR) +// +// The tone-mapping display parameters (exposure, gamma, channel mode) +// are stored here but evaluated in the fragment shader on the GPU. + +class ImageData : public AbstractFeature { + public: + using Format = visualization::vulkan::VulkanTexture::Format; + using DirtyRegion = geometry::BoundingBox; + + ImageData() = default; + + // ── Pixel mutators ────────────────────────────────────────────── + // Each bumps the version counter. + + // Set the full image. Copies the data. + auto set_pixels(uint32_t width, + uint32_t height, + Format format, + std::span data) -> void; + + // Convenience overloads. + auto set_pixels_rgba8(uint32_t width, + uint32_t height, + std::span rgba) -> void; + auto set_pixels_rgbaf32(uint32_t width, + uint32_t height, + std::span rgba) -> void; + + // Partial update — marks a dirty region. + // The data must be tightly packed (w * h * bytes_per_pixel). + auto update_region(uint32_t x, + uint32_t y, + uint32_t w, + uint32_t h, + std::span data) -> void; + + // ── Accessors ─────────────────────────────────────────────────── + + auto width() const -> uint32_t { return _width; } + auto height() const -> uint32_t { return _height; } + auto format() const -> Format { return _format; } + auto pixels() const -> std::span { return _pixels; } + auto has_pixels() const -> bool { return !_pixels.empty(); } + + // Bytes per pixel for the current format. + auto bytes_per_pixel() const -> size_t; + + // ── Dirty tracking ────────────────────────────────────────────── + + auto version() const -> uint64_t { return _version; } + + // Dirty region: the bounding box (in pixel coordinates) that changed + // since last clear. min() gives the top-left corner, width()/height() + // give the extent. If no partial update has been done (or set_pixels + // was called), this covers the full image. + auto dirty_region() const -> std::optional { return _dirty; } + auto clear_dirty() -> void { _dirty = std::nullopt; } + + // Was the full image replaced since last clear? + // (As opposed to just a partial region update.) + auto is_full_dirty() const -> bool { return _full_dirty; } + + // ── Display parameters ────────────────────────────────────────── + // Tone mapping (for HDR float images). + + auto exposure() const -> float { return _exposure; } + auto set_exposure(float ev) -> void { _exposure = ev; } + + auto gamma() const -> float { return _gamma; } + auto set_gamma(float g) -> void { _gamma = g; } + + // Channel display mode. + enum class ChannelMode : int { + RGBA = 0, + Red = 1, + Green = 2, + Blue = 3, + Alpha = 4, + Luminance = 5, + }; + auto channel_mode() const -> ChannelMode { return _channel_mode; } + auto set_channel_mode(ChannelMode mode) -> void { _channel_mode = mode; } + + private: + std::vector _pixels; + uint32_t _width = 0, _height = 0; + Format _format = Format::RGBA8; + uint64_t _version = 0; + std::optional _dirty; + bool _full_dirty = false; + + float _exposure = 0.0f; + float _gamma = 2.2f; + ChannelMode _channel_mode = ChannelMode::RGBA; +}; + +} // namespace balsa::scene_graph + +#endif diff --git a/visualization/include/balsa/scene_graph/MeshData.hpp b/visualization/include/balsa/scene_graph/MeshData.hpp index ebbd840..e6dc93d 100644 --- a/visualization/include/balsa/scene_graph/MeshData.hpp +++ b/visualization/include/balsa/scene_graph/MeshData.hpp @@ -2,34 +2,100 @@ #define BALSA_SCENE_GRAPH_MESH_DATA_HPP #include -#include +#include #include +#include +#include +#include #include "AbstractFeature.hpp" -#include "types.hpp" #include "balsa/visualization/vulkan/mesh_render_state.hpp" +#include "types.hpp" -#include -#include +#include +#include +#include namespace balsa::scene_graph { +// ── DiscoveredAttribute ───────────────────────────────────────────── +// +// Metadata about a single attribute found in the source MeshBase's +// AttributeManager. Populated by MeshData::set_mesh() and exposed +// for UI enumeration (attribute selector combos, property panels). + +struct DiscoveredAttribute { + quiver::attributes::ConstTypeErasedAttributeHandle handle; + std::string name; // from AttributeManager::name_of() + int8_t dimension = -1; // quiver dim (0=vertex, 1=edge, 2=face, ...) + std::type_index type_id; // from StoredAttributeBase::value_type_id() + std::size_t element_size = 0; // bytes per element + std::size_t count = 0; // number of elements + uint8_t component_count = + 0; // 1 for scalar, 2 for array, 3 for array, ... + bool is_floating_point = false; // true for float/double element types + + DiscoveredAttribute() : type_id(typeid(void)) {} +}; + +// ── RoleBinding ───────────────────────────────────────────────────── +// +// Binds a visualization role (positions, normals, scalar field) to +// an attribute from the source mesh. +// +// When the source attribute is double-typed, a CachedAttribute +// (TransformAttribute → StoredAttribute) is created to +// provide GPU-ready float data. When the source is already float, +// gpu_ready points directly at the source — zero copy. +// +// The sync layer reads raw_data() + component_count from gpu_ready +// to upload to the GPU with the correct VkFormat. + +struct RoleBinding { + // Handle to the original attribute in the source mesh. + quiver::attributes::ConstTypeErasedAttributeHandle source; + + // Owned CachedAttribute that materialises a float conversion + // of the source. Null when source is already float-typed and + // gpu_ready points directly at source. + std::unique_ptr cached; + + // Handle to whichever attribute the GPU should read from. + // Points to cached (if present) or source. + quiver::attributes::ConstTypeErasedAttributeHandle gpu_ready; + + // Number of float components per element in gpu_ready (1, 2, 3, 4). + uint8_t component_count = 0; + + // Whether this role is bound to a valid attribute. + bool is_bound() const { return gpu_ready.valid(); } + + // Number of elements in the gpu_ready attribute. + std::size_t size() const; + + // Raw pointer to contiguous float data for GPU upload. + // Returns nullptr if not bound. + const void *raw_data() const; + + // Reset the binding to empty. + void clear(); +}; + // ── MeshData ──────────────────────────────────────────────────────── // // Feature that holds CPU-side mesh geometry and appearance parameters. // -// Uses quiver's AttributeManager as the backing store for vertex/index -// data and an optional quiver::Mesh<2> for triangle mesh topology. -// When triangle indices are set, edges are automatically derived from -// the topology's 1-skeleton so that wireframe rendering works without -// explicit edge data. +// Backed by a shared quiver::MeshBase. All attributes from the mesh +// are enumerable and assignable to visualization roles (positions, +// normals, scalar field). The rendering layer reads GPU-ready data +// directly from the role bindings' raw_data() pointers. // -// Each mutator bumps a version counter so that the rendering layer -// can detect when GPU buffers need re-uploading. +// Topology-derived data (triangle and edge index buffers) is +// extracted from the mesh's skeletons and stored as flat uint32_t +// arrays, since quiver's topology representation is not contiguous. // -// The render_state member holds shading, material, and display -// parameters. It uses the existing MeshRenderState struct from the -// Vulkan layer (which is renderer-agnostic despite its namespace). +// The version counter is bumped on set_mesh() and role reassignment +// so the rendering layer knows when GPU buffers need re-uploading. class MeshData : public AbstractFeature { public: @@ -37,46 +103,78 @@ class MeshData : public AbstractFeature { MeshData(); - // ── Geometry mutators ─────────────────────────────────────────── - // Each setter copies the data and bumps the version counter. + // ── Mesh source ───────────────────────────────────────────────── + // + // Store a quiver MeshBase as the source of all attribute data. + // Enumerates attributes, auto-assigns roles by convention name + // (vertex_positions → position, vertex_normals → normal), builds + // skeletons, extracts topology indices. + + void set_mesh(std::shared_ptr mesh); + + const quiver::MeshBase *mesh() const { return _mesh.get(); } + + // ── Attribute discovery ───────────────────────────────────────── + + const std::vector &discovered_attributes() const { + return _discovered; + } + + // ── Role assignment ───────────────────────────────────────────── + // + // Bind an attribute to a visualization role. The handle must + // reference an attribute owned by the source mesh's + // AttributeManager. If the attribute type is incompatible + // (e.g. non-numeric for positions), the binding is rejected + // and the role is left unbound. + // + // For scalar roles, component selects which element of a + // multi-component attribute to use: -1 = magnitude, + // 0/1/2/... = specific component. + + void assign_position( + quiver::attributes::ConstTypeErasedAttributeHandle handle); + void assign_normal( + quiver::attributes::ConstTypeErasedAttributeHandle handle); + void + assign_scalar(quiver::attributes::ConstTypeErasedAttributeHandle handle, + int component = -1); + + // Clear a role binding. + void clear_position(); + void clear_normal(); + void clear_scalar(); + + // ── Role accessors ────────────────────────────────────────────── + + const RoleBinding &position_binding() const { return _position; } + const RoleBinding &normal_binding() const { return _normal; } + const RoleBinding &scalar_binding() const { return _scalar; } + + int scalar_component() const { return _scalar_component; } + + // ── Topology index buffers ────────────────────────────────────── + // + // Extracted from the mesh's skeletons. Triangle indices come + // from Skeleton<0> (simplex-vertex connectivity); edge indices + // come from Skeleton<1> + IncidentFaceIndices. - void set_positions(std::span positions); - void set_normals(std::span normals); - void set_triangle_indices(std::span indices); - void set_edge_indices(std::span indices); - void set_vertex_colors(std::span colors); - void set_scalar_field(std::span scalars); - - // ── Geometry accessors ────────────────────────────────────────── - - std::span positions() const; - std::span normals() const; std::span triangle_indices() const; std::span edge_indices() const; - std::span vertex_colors() const; - std::span scalar_field() const; - bool has_positions() const; - bool has_normals() const; - bool has_triangle_indices() const; - bool has_edge_indices() const; - bool has_vertex_colors() const; - bool has_scalar_field() const; + // ── Convenience queries ───────────────────────────────────────── - std::size_t vertex_count() const; - std::size_t triangle_count() const; - std::size_t edge_count() const; + bool has_positions() const { return _position.is_bound(); } + bool has_normals() const { return _normal.is_bound(); } + bool has_scalar_field() const { return _scalar.is_bound(); } + bool has_triangle_indices() const { return !_tri_indices.empty(); } + bool has_edge_indices() const { return !_edge_indices.empty(); } - // ── Topology ──────────────────────────────────────────────────── - // Access the quiver Mesh<2> topology (built from triangle indices). - - bool has_topology() const { return _topology.has_value(); } - const quiver::Mesh<2> &topology() const { return *_topology; } + std::size_t vertex_count() const; + std::size_t triangle_count() const { return _tri_indices.size() / 3; } + std::size_t edge_count() const { return _edge_indices.size() / 2; } // ── Dirty tracking ────────────────────────────────────────────── - // The version is bumped on every mutator call. The rendering - // layer compares against its last-synced version to decide - // whether GPU buffers need updating. uint64_t version() const { return _version; } @@ -86,31 +184,45 @@ class MeshData : public AbstractFeature { const RenderState &render_state() const { return _render_state; } private: - // Attribute backing store. - quiver::attributes::AttributeManager _attrs; + // Source mesh (shared ownership with the reader/caller). + std::shared_ptr _mesh; + + // All attributes discovered from the source mesh. + std::vector _discovered; + + // Role bindings (position, normal, scalar). + RoleBinding _position; + RoleBinding _normal; + RoleBinding _scalar; + int _scalar_component = -1; + + // Flat index buffers extracted from mesh topology. + std::vector _tri_indices; + std::vector _edge_indices; + + // ── Private helpers ───────────────────────────────────────────── - // Cached handles for fast access (set once in constructor). - quiver::attributes::AttributeHandle _h_positions; - quiver::attributes::AttributeHandle _h_normals; - quiver::attributes::AttributeHandle _h_triangle_indices; - quiver::attributes::AttributeHandle _h_edge_indices; - quiver::attributes::AttributeHandle _h_vertex_colors; - quiver::attributes::AttributeHandle _h_scalar_field; + // Enumerate all attributes from the source mesh into _discovered. + void discover_attributes(); - // Triangle mesh topology — built from triangle indices. - // When present, edges are derived from the 1-skeleton. - std::optional> _topology; + // Extract triangle and edge index buffers from mesh topology. + void extract_topology_indices(); - // Whether edges were explicitly set by the user (vs auto-derived). - bool _explicit_edges = false; + // Build a RoleBinding for a vector-valued attribute (positions/normals). + // Returns an unbound RoleBinding if the handle is invalid or incompatible. + static RoleBinding build_vector_binding( + quiver::attributes::ConstTypeErasedAttributeHandle handle); - // Derive edge indices from the Mesh<2> topology's 1-skeleton. - void derive_edges_from_topology(); + // Build a RoleBinding for a scalar attribute (single float per vertex). + // Extracts a specific component from multi-component types, or magnitude. + static RoleBinding build_scalar_binding( + quiver::attributes::ConstTypeErasedAttributeHandle handle, + int component); RenderState _render_state; uint64_t _version = 0; }; -}// namespace balsa::scene_graph +} // namespace balsa::scene_graph #endif diff --git a/visualization/include/balsa/visualization/image_io.hpp b/visualization/include/balsa/visualization/image_io.hpp new file mode 100644 index 0000000..af76e3d --- /dev/null +++ b/visualization/include/balsa/visualization/image_io.hpp @@ -0,0 +1,47 @@ +#if !defined(BALSA_VISUALIZATION_IMAGE_IO_HPP) +#define BALSA_VISUALIZATION_IMAGE_IO_HPP + +#include +#include +#include +#include +#include + +namespace balsa::visualization { + +// ── PPM image I/O ────────────────────────────────────────────────── +// +// Minimal PPM (Portable Pixmap) reader and writer. +// Supports P6 (binary RGB, 8-bit) format only. +// No external dependencies. + +struct ImageBuffer { + uint32_t width = 0; + uint32_t height = 0; + // RGBA8 pixel data (4 bytes per pixel, row-major, top-to-bottom). + std::vector pixels; +}; + +enum class ImageIOError { + FileNotFound, + InvalidFormat, + ReadError, + WriteError, +}; + +auto error_string(ImageIOError err) -> std::string_view; + +// Load a PPM (P6) image file. Returns RGBA8 pixel data (the PPM RGB +// is expanded to RGBA with alpha = 255). +auto load_ppm(const std::string &path) + -> std::expected; + +// Save an RGBA8 image as a PPM (P6) file. Alpha channel is discarded. +auto save_ppm(const std::string &path, + uint32_t width, + uint32_t height, + const uint8_t *rgba_pixels) -> std::expected; + +} // namespace balsa::visualization + +#endif diff --git a/visualization/include/balsa/visualization/qt/mesh_controls_widget.hpp b/visualization/include/balsa/visualization/qt/mesh_controls_widget.hpp index 21dba50..24b2fe4 100644 --- a/visualization/include/balsa/visualization/qt/mesh_controls_widget.hpp +++ b/visualization/include/balsa/visualization/qt/mesh_controls_widget.hpp @@ -17,13 +17,13 @@ class QBoxLayout; namespace balsa::visualization::vulkan { class MeshScene; struct MeshRenderState; -}// namespace balsa::visualization::vulkan +} // namespace balsa::visualization::vulkan namespace balsa::scene_graph { class Object; class MeshData; class BVHData; -}// namespace balsa::scene_graph +} // namespace balsa::scene_graph namespace balsa::visualization::qt { @@ -107,6 +107,12 @@ class MeshControlsWidget : public QWidget { void on_scene_light_dir_changed(); void on_scene_light_color_clicked(); + // Attribute bindings + void on_position_attr_changed(int index); + void on_normal_attr_changed(int index); + void on_scalar_attr_changed(int index); + void on_scalar_component_changed(int index); + // BVH overlay void on_bvh_enabled_changed(bool checked); void on_bvh_kdop_changed(int index); @@ -141,6 +147,7 @@ class MeshControlsWidget : public QWidget { void build_render_state_group(QWidget *parent, QBoxLayout *layout); void build_color_group(QWidget *parent, QBoxLayout *layout); void build_layers_group(QWidget *parent, QBoxLayout *layout); + void build_attribute_bindings_group(QWidget *parent, QBoxLayout *layout); void build_material_group(QWidget *parent, QBoxLayout *layout); void build_scene_lighting_group(QWidget *parent, QBoxLayout *layout); void build_bvh_group(QWidget *parent, QBoxLayout *layout); @@ -150,6 +157,7 @@ class MeshControlsWidget : public QWidget { void sync_from_state(); void sync_color_group_visibility(); void sync_transform_from_object(); + void sync_attribute_bindings(); // Helper: get the MeshData feature from the selected Object, or nullptr. ::balsa::scene_graph::MeshData *selected_mesh_data(); @@ -197,7 +205,7 @@ class MeshControlsWidget : public QWidget { QComboBox *_normal_source_combo = nullptr; QCheckBox *_two_sided_check = nullptr; QComboBox *_cull_mode_combo = nullptr; - QWidget *_shading_details_container = nullptr;// shown only when lit + QWidget *_shading_details_container = nullptr; // shown only when lit // ── Color widgets ──────────────────────────────────────────────── QComboBox *_color_source_combo = nullptr; @@ -255,8 +263,16 @@ class MeshControlsWidget : public QWidget { QLabel *_bvh_depth_label = nullptr; QLabel *_bvh_height_label = nullptr; QPushButton *_bvh_color_button = nullptr; + + // ── Attribute binding widgets ──────────────────────────────────── + QGroupBox *_attr_bindings_group = nullptr; + QComboBox *_position_attr_combo = nullptr; + QComboBox *_normal_attr_combo = nullptr; + QComboBox *_scalar_attr_combo = nullptr; + QComboBox *_scalar_component_combo = nullptr; + QWidget *_scalar_component_container = nullptr; }; -}// namespace balsa::visualization::qt +} // namespace balsa::visualization::qt #endif diff --git a/visualization/include/balsa/visualization/vulkan/image_pipeline.hpp b/visualization/include/balsa/visualization/vulkan/image_pipeline.hpp new file mode 100644 index 0000000..d5289e1 --- /dev/null +++ b/visualization/include/balsa/visualization/vulkan/image_pipeline.hpp @@ -0,0 +1,126 @@ +#if !defined(BALSA_VISUALIZATION_VULKAN_IMAGE_PIPELINE_HPP) +#define BALSA_VISUALIZATION_VULKAN_IMAGE_PIPELINE_HPP + +#include + +#include "balsa/scene_graph/types.hpp" + +namespace balsa::visualization::vulkan { + +class Film; + +// ── UBO structs for image rendering (must match GLSL, std140) ─────── + +// binding = 0 in image.vert +struct ImageTransformUBO { + scene_graph::Mat4f mvp; // 64 bytes +}; +static_assert(sizeof(ImageTransformUBO) == 64, + "ImageTransformUBO must be 64 bytes"); + +// binding = 1 in image.frag +struct ImageParamsUBO { + scene_graph::Vec4f + tone_params; // x=exposure, y=gamma, z=channel_mode, w=unused + scene_graph::Vec4f image_size; // x=width, y=height, z=1/width, w=1/height +}; +static_assert(sizeof(ImageParamsUBO) == 32, "ImageParamsUBO must be 32 bytes"); + +// ── ImagePipelineManager ──────────────────────────────────────────── +// +// Owns the descriptor set layout, pipeline layout, descriptor pool, +// and a single graphics pipeline for image (textured fullscreen +// triangle) rendering. +// +// Descriptor set layout: +// binding 0: ImageTransformUBO (uniform buffer, vertex stage) +// binding 1: ImageParamsUBO (uniform buffer, fragment stage) +// binding 2: combined image sampler (fragment stage) +// +// The pipeline uses the fullscreen triangle technique (3 vertices, +// no vertex buffer) with the image.vert / image.frag shaders. +// +// Thread-safety: NOT thread-safe — call only from the rendering thread. + +class ImagePipelineManager { + public: + ImagePipelineManager() = default; + ~ImagePipelineManager(); + + // Non-copyable, movable + ImagePipelineManager(const ImagePipelineManager &) = delete; + auto operator=(const ImagePipelineManager &) -> ImagePipelineManager & = delete; + ImagePipelineManager(ImagePipelineManager &&) noexcept; + auto operator=(ImagePipelineManager &&) noexcept -> ImagePipelineManager &; + + // Initialise with a Film reference. Must be called before any + // other method. + auto init(Film &film, uint32_t max_descriptor_sets = 16) -> void; + + // ── Pipeline access ───────────────────────────────────────────── + + // Get (or lazily create) the pipeline. The Film is queried for + // render-pass / MSAA / depth-stencil info. + auto get_or_create(Film &film) -> vk::Pipeline; + + // The shared pipeline layout. + auto pipeline_layout() const -> vk::PipelineLayout { return _pipeline_layout; } + + // ── Descriptor sets ───────────────────────────────────────────── + + // Allocate a descriptor set from the internal pool. + auto allocate_descriptor_set() -> vk::DescriptorSet; + + // Write buffer + image bindings into a descriptor set. + // binding 0 = ImageTransformUBO (uniform buffer) + // binding 1 = ImageParamsUBO (uniform buffer) + // binding 2 = combined image sampler + auto write_descriptor_set(vk::DescriptorSet ds, + vk::Buffer transform_buffer, + vk::DeviceSize transform_size, + vk::Buffer params_buffer, + vk::DeviceSize params_size, + vk::ImageView image_view, + vk::Sampler sampler) -> void; + + // Free a descriptor set back to the pool. + auto free_descriptor_set(vk::DescriptorSet ds) -> void; + + // ── Lifecycle ─────────────────────────────────────────────────── + + // Destroy the cached pipeline (e.g. on swapchain recreation). + auto invalidate_pipeline() -> void; + + // Destroy everything. Safe to call multiple times. + auto release() -> void; + + auto is_initialized() const -> bool { return _initialized; } + + private: + auto create_pipeline() -> vk::Pipeline; + + auto create_descriptor_set_layout() -> void; + auto create_pipeline_layout() -> void; + auto create_descriptor_pool(uint32_t max_sets) -> void; + + vk::Device _device; + Film *_film = nullptr; + + vk::DescriptorSetLayout _descriptor_set_layout; + vk::PipelineLayout _pipeline_layout; + vk::DescriptorPool _descriptor_pool; + + // Pipeline key: (render_pass, msaa_samples, depth_test). + // For simplicity we store a single cached pipeline and + // invalidate on render pass changes (same as swapchain recreate). + vk::Pipeline _pipeline; + uint64_t _cached_render_pass = 0; + uint32_t _cached_msaa_samples = 0; + bool _cached_depth_test = false; + + bool _initialized = false; +}; + +} // namespace balsa::visualization::vulkan + +#endif diff --git a/visualization/include/balsa/visualization/vulkan/image_scene.hpp b/visualization/include/balsa/visualization/vulkan/image_scene.hpp new file mode 100644 index 0000000..bb59283 --- /dev/null +++ b/visualization/include/balsa/visualization/vulkan/image_scene.hpp @@ -0,0 +1,150 @@ +#if !defined(BALSA_VISUALIZATION_VULKAN_IMAGE_SCENE_HPP) +#define BALSA_VISUALIZATION_VULKAN_IMAGE_SCENE_HPP + +#include +#include +#include +#include + +#include "balsa/scene_graph/Camera.hpp" +#include "balsa/scene_graph/DrawableGroup.hpp" +#include "balsa/scene_graph/ImageData.hpp" +#include "balsa/scene_graph/Object.hpp" +#include "balsa/visualization/vulkan/image_pipeline.hpp" +#include "balsa/visualization/vulkan/scene_base.hpp" + +namespace balsa::visualization::vulkan { + +class VulkanImageDrawable; + +// ── ImageScene ────────────────────────────────────────────────────── +// +// SceneBase subclass for 2D image viewing with orthographic projection, +// pan/zoom navigation, and tone-mapping display parameters. +// +// Owns: +// - A root scene_graph::Object (the scene graph root) +// - A camera Object (child of root) with Camera feature +// - A DrawableGroup (flat registry of VulkanImageDrawables) +// - An ImagePipelineManager (shared by all drawables) +// +// The scene manages a single image Object internally. Pan and zoom +// manipulate the orthographic projection / MVP override on the +// drawable, keeping the image's texture coordinates unchanged. +// +// Lifecycle: +// 1. Construct ImageScene +// 2. Set image data via set_image() or image_data() +// 3. Call initialize(film) — creates pipeline manager, inits drawables +// 4. Each frame: Window calls begin_render_pass -> draw -> end_render_pass +// 5. On teardown: release_vulkan_resources() +// +// Thread-safety: NOT thread-safe — call only from the rendering thread. + +class ImageScene : public SceneBase { + public: + ImageScene(); + ~ImageScene() override; + + // Non-copyable, non-movable + ImageScene(const ImageScene &) = delete; + auto operator=(const ImageScene &) -> ImageScene & = delete; + ImageScene(ImageScene &&) = delete; + auto operator=(ImageScene &&) -> ImageScene & = delete; + + // ── SceneBase overrides ───────────────────────────────────────── + + auto initialize(Film &film) -> void override; + auto draw(Film &film) -> void override; + auto release_vulkan_resources() -> void override; + + // ── Image management ──────────────────────────────────────────── + + // Set the full image data. Creates the internal ImageData + + // Object + VulkanImageDrawable on first call. + auto set_image(uint32_t width, + uint32_t height, + scene_graph::ImageData::Format format, + std::span pixels) -> void; + + // Convenience overloads. + auto set_image_rgba8(uint32_t width, + uint32_t height, + std::span rgba) -> void; + auto set_image_rgbaf32(uint32_t width, + uint32_t height, + std::span rgba) -> void; + + // Access the underlying ImageData for direct manipulation + // (e.g., partial updates from a live render, or changing + // tone-mapping parameters). + // Returns nullptr if no image has been set yet. + auto image_data() -> scene_graph::ImageData *; + auto image_data() const -> const scene_graph::ImageData *; + + auto has_image() const -> bool; + + // ── 2D navigation ─────────────────────────────────────────────── + + // Zoom level: 1.0 = 1 image pixel = 1 screen pixel (at fit). + // Values > 1 zoom in, < 1 zoom out. + auto set_zoom(float zoom) -> void; + auto zoom() const -> float { return _zoom; } + + // Pan offset in NDC units (-1 to 1). + auto set_pan(float x, float y) -> void; + auto pan_x() const -> float { return _pan_x; } + auto pan_y() const -> float { return _pan_y; } + + // Reset zoom and pan to fit the image in the viewport. + auto fit_to_window() -> void; + + // ── Camera ────────────────────────────────────────────────────── + + auto camera() -> scene_graph::Camera &; + auto camera() const -> const scene_graph::Camera &; + + // ── Scene graph access ────────────────────────────────────────── + + auto root() -> scene_graph::Object & { return _scene_root; } + auto root() const -> const scene_graph::Object & { return _scene_root; } + + auto drawable_group() -> scene_graph::DrawableGroup & { return _drawable_group; } + auto drawable_group() const -> const scene_graph::DrawableGroup & { + return _drawable_group; + } + + auto pipeline_manager() -> ImagePipelineManager & { return _pipeline_manager; } + auto pipeline_manager() const -> const ImagePipelineManager & { + return _pipeline_manager; + } + + private: + // Ensure the image Object and its features exist. + auto ensure_image_object() -> void; + + // Recompute the MVP matrix from zoom/pan state and update the + // VulkanImageDrawable's override. + auto update_mvp() -> void; + + // Scene graph + scene_graph::Object _scene_root; + scene_graph::Object *_camera_object = nullptr; + scene_graph::Camera *_camera = nullptr; + scene_graph::Object *_image_object = nullptr; + scene_graph::DrawableGroup _drawable_group; + + // Vulkan resources + ImagePipelineManager _pipeline_manager; + Film *_film = nullptr; + bool _initialized = false; + + // 2D navigation state + float _zoom = 1.0f; + float _pan_x = 0.0f; + float _pan_y = 0.0f; +}; + +} // namespace balsa::visualization::vulkan + +#endif diff --git a/visualization/include/balsa/visualization/vulkan/imgui/image_controls_panel.hpp b/visualization/include/balsa/visualization/vulkan/imgui/image_controls_panel.hpp new file mode 100644 index 0000000..92612cd --- /dev/null +++ b/visualization/include/balsa/visualization/vulkan/imgui/image_controls_panel.hpp @@ -0,0 +1,28 @@ +#if !defined(BALSA_VISUALIZATION_VULKAN_IMGUI_IMAGE_CONTROLS_PANEL_HPP) +#define BALSA_VISUALIZATION_VULKAN_IMGUI_IMAGE_CONTROLS_PANEL_HPP + +namespace balsa::visualization::vulkan { + +class ImageScene; + +namespace imgui { + + // Persistent state for the image controls panel. + // Caller owns this and passes it to draw_image_controls() each frame. + struct ImagePanelState { + bool show_controls = true; + }; + + // Draw the image display controls panel (exposure, gamma, channel + // mode, zoom/pan, fit-to-window button, image info). + // + // Assumes ImGui::NewFrame() has been called and the caller will + // call ImGui::Render() afterward. + // + // Returns true if any value was modified this frame. + auto draw_image_controls(ImageScene &scene, ImagePanelState &state) -> bool; + +} // namespace imgui +} // namespace balsa::visualization::vulkan + +#endif diff --git a/visualization/include/balsa/visualization/vulkan/mesh_buffers.hpp b/visualization/include/balsa/visualization/vulkan/mesh_buffers.hpp index d7cf314..31b1c98 100644 --- a/visualization/include/balsa/visualization/vulkan/mesh_buffers.hpp +++ b/visualization/include/balsa/visualization/vulkan/mesh_buffers.hpp @@ -18,22 +18,25 @@ class Film; // binding = 0 in mesh.vert / mesh.frag struct TransformUBO { - scene_graph::Mat4f model;// 64 bytes - scene_graph::Mat4f view;// 64 bytes - scene_graph::Mat4f projection;// 64 bytes - scene_graph::Mat4f normal_matrix;// 64 bytes (transpose(inverse(model))) - scene_graph::Vec4f camera_pos;// 16 bytes (xyz = world-space camera position, w = pad) + scene_graph::Mat4f model; // 64 bytes + scene_graph::Mat4f view; // 64 bytes + scene_graph::Mat4f projection; // 64 bytes + scene_graph::Mat4f normal_matrix; // 64 bytes (transpose(inverse(model))) + scene_graph::Vec4f + camera_pos; // 16 bytes (xyz = world-space camera position, w = pad) }; static_assert(sizeof(TransformUBO) == 272, "TransformUBO must be 272 bytes"); // binding = 1 in mesh.vert / mesh.frag struct MaterialUBO { - scene_graph::Vec4f uniform_color;// rgba - scene_graph::Vec4f light_dir;// xyz = direction, w = ambient_strength - scene_graph::Vec4f light_color;// xyz = light color, w = unused (pad) - scene_graph::Vec4f specular_params;// xyz = specular_color, w = shininess - scene_graph::Vec4f scalar_params;// x = scalar_min, y = scalar_max, z = point_size, w = two_sided (>0.5) - scene_graph::Vec4f layer_color;// rgba — per-layer color (solid/wireframe/point) + scene_graph::Vec4f uniform_color; // rgba + scene_graph::Vec4f light_dir; // xyz = direction, w = ambient_strength + scene_graph::Vec4f light_color; // xyz = light color, w = unused (pad) + scene_graph::Vec4f specular_params; // xyz = specular_color, w = shininess + scene_graph::Vec4f scalar_params; // x = scalar_min, y = scalar_max, z = + // point_size, w = two_sided (>0.5) + scene_graph::Vec4f + layer_color; // rgba — per-layer color (solid/wireframe/point) }; static_assert(sizeof(MaterialUBO) == 96, "MaterialUBO must be 96 bytes"); @@ -50,24 +53,24 @@ inline vk::DeviceSize material_ubo_aligned_size(vk::DeviceSize min_alignment) { return (raw + min_alignment - 1) & ~(min_alignment - 1); } - // ── MeshBuffers ────────────────────────────────────────────────────── // // Owns GPU-side VulkanBuffers for mesh vertex attributes and index // buffers. Each attribute is an optional separate VkBuffer (matching // the separate-binding vertex layout in mesh.vert). // -// binding 0: positions (vec3, location 0) -// binding 1: normals (vec3, location 1) -// binding 2: colors (vec4, location 2) -// binding 3: scalars (float, location 3) +// binding 0: positions (vec2/vec3, location 0) — format-aware +// binding 1: normals (vec2/vec3, location 1) — format-aware +// binding 2: colors (vec4, location 2) +// binding 3: scalars (float, location 3) +// +// Positions and normals support variable component counts (1–4 float +// components). Vulkan auto-fills missing components in the vertex +// shader: a R32G32_SFLOAT attribute read as vec3 yields (x, y, 0.0). // // Two index buffers: // triangle_indices (uint32, for solid/flat/phong draws) // edge_indices (uint32, for wireframe draws) -// -// Upload methods accept raw float/uint32 spans. Convenience overloads -// accept TriangleMesh/OBJMesh (with size_t → uint32_t index conversion). class MeshBuffers { public: @@ -82,11 +85,21 @@ class MeshBuffers { // ── Upload raw data ───────────────────────────────────────────── - // Positions: N vertices × 3 floats (tightly packed vec3[]). - void upload_positions(Film &film, std::span data, uint32_t vertex_count); - - // Normals: N vertices × 3 floats (tightly packed vec3[]). - void upload_normals(Film &film, std::span data); + // Positions: raw float data with variable component count (1–4). + // byte_size = vertex_count * components * sizeof(float). + // Vulkan auto-fills missing components when the shader reads vec3. + void upload_positions(Film &film, + const void *data, + std::size_t byte_size, + uint32_t vertex_count, + uint8_t components); + + // Normals: raw float data with variable component count (1–4). + // byte_size = vertex_count * components * sizeof(float). + void upload_normals(Film &film, + const void *data, + std::size_t byte_size, + uint8_t components); // Per-vertex colors: N vertices × 4 floats (tightly packed vec4[], RGBA). void upload_colors(Film &film, std::span data); @@ -95,15 +108,23 @@ class MeshBuffers { void upload_scalars(Film &film, std::span data); // Triangle indices: T triangles × 3 uint32. - void upload_triangle_indices(Film &film, std::span data, uint32_t triangle_count); + void upload_triangle_indices(Film &film, + std::span data, + uint32_t triangle_count); // Edge indices: E edges × 2 uint32. - void upload_edge_indices(Film &film, std::span data, uint32_t edge_count); + void upload_edge_indices(Film &film, + std::span data, + uint32_t edge_count); // ── Upload from size_t index data (performs narrowing conversion) ─ - void upload_triangle_indices_from_sizet(Film &film, std::span data, uint32_t triangle_count); - void upload_edge_indices_from_sizet(Film &film, std::span data, uint32_t edge_count); + void upload_triangle_indices_from_sizet(Film &film, + std::span data, + uint32_t triangle_count); + void upload_edge_indices_from_sizet(Film &film, + std::span data, + uint32_t edge_count); // ── Release all GPU resources ─────────────────────────────────── @@ -115,6 +136,12 @@ class MeshBuffers { uint32_t triangle_count() const { return _triangle_count; } uint32_t edge_count() const { return _edge_count; } + // Number of float components per position vertex (1–4, default 3). + uint8_t position_components() const { return _position_components; } + + // Number of float components per normal vertex (1–4, default 3). + uint8_t normal_components() const { return _normal_components; } + bool has_positions() const { return _positions.is_valid(); } bool has_normals() const { return _normals.is_valid(); } bool has_colors() const { return _colors.is_valid(); } @@ -127,17 +154,20 @@ class MeshBuffers { vk::Buffer normals_buffer() const { return _normals.buffer(); } vk::Buffer colors_buffer() const { return _colors.buffer(); } vk::Buffer scalars_buffer() const { return _scalars.buffer(); } - vk::Buffer triangle_index_buffer() const { return _triangle_indices.buffer(); } + vk::Buffer triangle_index_buffer() const { + return _triangle_indices.buffer(); + } vk::Buffer edge_index_buffer() const { return _edge_indices.buffer(); } // ── Vertex input descriptions ─────────────────────────────────── // // Return Vulkan vertex input binding/attribute descriptions based - // on which attributes are currently populated. Used when creating - // the graphics pipeline. + // on which attributes are currently populated. Uses stored + // component counts for position/normal formats. std::vector binding_descriptions() const; - std::vector attribute_descriptions() const; + std::vector + attribute_descriptions() const; private: VulkanBuffer _positions; @@ -150,8 +180,10 @@ class MeshBuffers { uint32_t _vertex_count = 0; uint32_t _triangle_count = 0; uint32_t _edge_count = 0; + uint8_t _position_components = 3; + uint8_t _normal_components = 3; }; -}// namespace balsa::visualization::vulkan +} // namespace balsa::visualization::vulkan #endif diff --git a/visualization/include/balsa/visualization/vulkan/mesh_pipeline.hpp b/visualization/include/balsa/visualization/vulkan/mesh_pipeline.hpp index 5370a8b..a97f42e 100644 --- a/visualization/include/balsa/visualization/vulkan/mesh_pipeline.hpp +++ b/visualization/include/balsa/visualization/vulkan/mesh_pipeline.hpp @@ -41,8 +41,14 @@ struct MeshPipelineKey { bool has_colors = false; bool has_scalars = false; + // Position/normal component counts (1–4). Affects VkFormat in the + // vertex input layout. Vulkan auto-fills missing components when + // the shader declares vec3 but the buffer provides fewer floats. + uint8_t position_components = 3; + uint8_t normal_components = 3; + // Rasterization state - CullMode cull_mode = CullMode::None;// which faces to discard + CullMode cull_mode = CullMode::None; // which faces to discard // Wireframe overlay — when true, the fragment shader uses // gl_BaryCoordEXT (VK_KHR_fragment_shader_barycentric) to draw @@ -52,8 +58,8 @@ struct MeshPipelineKey { bool wireframe_overlay = false; // Render-pass-dependent state (queried from Film) - uint32_t msaa_samples = 1;// underlying value of VkSampleCountFlagBits - uint64_t render_pass = 0;// VkRenderPass cast to uint64_t for hashing + uint32_t msaa_samples = 1; // underlying value of VkSampleCountFlagBits + uint64_t render_pass = 0; // VkRenderPass cast to uint64_t for hashing bool depth_test = false; bool operator==(const MeshPipelineKey &) const = default; @@ -158,11 +164,12 @@ class MeshPipelineManager { vk::PipelineLayout _pipeline_layout; vk::DescriptorPool _descriptor_pool; - std::unordered_map _cache; + std::unordered_map + _cache; bool _initialized = false; }; -}// namespace balsa::visualization::vulkan +} // namespace balsa::visualization::vulkan #endif diff --git a/visualization/include/balsa/visualization/vulkan/texture.hpp b/visualization/include/balsa/visualization/vulkan/texture.hpp new file mode 100644 index 0000000..b41518d --- /dev/null +++ b/visualization/include/balsa/visualization/vulkan/texture.hpp @@ -0,0 +1,112 @@ +#if !defined(BALSA_VISUALIZATION_VULKAN_TEXTURE_HPP) +#define BALSA_VISUALIZATION_VULKAN_TEXTURE_HPP + +#include +#include +#include +#include + +namespace balsa::visualization::vulkan { + +class Film; + +// ── VulkanTexture ─────────────────────────────────────────────────── +// +// RAII wrapper for a Vulkan texture (VkImage + VkImageView + VkSampler +// + VkDeviceMemory). Supports full and partial (rectangular region) +// uploads through a staging buffer. +// +// Designed for image viewer use (RGBA8 or RGBAF32 textures) but is a +// general-purpose reusable primitive (future: environment maps, +// texture-mapped meshes, LUT colormaps). +// +// Non-copyable, movable. Destroying or moving-from releases the +// Vulkan resources. + +class VulkanTexture { + public: + enum class Format { RGBA8, RGBAF32 }; + + VulkanTexture() = default; + ~VulkanTexture(); + + // Non-copyable + VulkanTexture(const VulkanTexture &) = delete; + auto operator=(const VulkanTexture &) -> VulkanTexture & = delete; + + // Movable + VulkanTexture(VulkanTexture &&) noexcept; + auto operator=(VulkanTexture &&) noexcept -> VulkanTexture &; + + // Create GPU resources (image, memory, view, sampler). + // The image is created device-local for optimal sampling. + // A persistent staging buffer is allocated for uploads. + auto create(Film &film, uint32_t width, uint32_t height, Format format) -> void; + + // Upload full image data. Stages through a host-visible buffer, + // copies to device-local memory via a one-shot command buffer. + // The image layout is transitioned: + // UNDEFINED -> TRANSFER_DST_OPTIMAL -> SHADER_READ_ONLY_OPTIMAL + auto upload(Film &film, const void *pixels, size_t byte_count) -> void; + + // Partial update: upload a rectangular sub-region. + // For progressive rendering — avoids re-uploading the entire image. + // Layout: SHADER_READ_ONLY -> TRANSFER_DST -> SHADER_READ_ONLY. + auto update_region(Film &film, + uint32_t x, + uint32_t y, + uint32_t w, + uint32_t h, + const void *pixels, + size_t byte_count) -> void; + + // Accessors for descriptor set binding + auto image_view() const -> vk::ImageView { return _image_view; } + auto sampler() const -> vk::Sampler { return _sampler; } + + auto width() const -> uint32_t { return _width; } + auto height() const -> uint32_t { return _height; } + auto format() const -> Format { return _format; } + + // Release all GPU resources. Safe to call multiple times. + auto release() -> void; + auto is_valid() const -> bool { return _image != vk::Image{}; } + + private: + // Record and execute a layout transition barrier. + auto transition_layout(vk::CommandBuffer cmd, + vk::ImageLayout old_layout, + vk::ImageLayout new_layout) -> void; + + // Execute a one-shot command buffer (record -> submit -> waitIdle). + // The callback receives the command buffer to record into. + auto one_shot_command(Film &film, + std::function record_fn) -> void; + + // Return the VkFormat corresponding to our Format enum. + auto vk_format() const -> vk::Format; + + // Bytes per pixel for the current format. + auto bytes_per_pixel() const -> size_t; + + vk::Device _device; + Film *_film = nullptr; + vk::Image _image; + vk::DeviceMemory _memory; + vk::ImageView _image_view; + vk::Sampler _sampler; + + // Persistent staging buffer for uploads. + // Sized to hold a full image. Re-created if the texture is + // recreated with different dimensions. + vk::Buffer _staging_buffer; + vk::DeviceMemory _staging_memory; + vk::DeviceSize _staging_size = 0; + + uint32_t _width = 0, _height = 0; + Format _format = Format::RGBA8; +}; + +} // namespace balsa::visualization::vulkan + +#endif diff --git a/visualization/include/balsa/visualization/vulkan/vulkan_image_drawable.hpp b/visualization/include/balsa/visualization/vulkan/vulkan_image_drawable.hpp new file mode 100644 index 0000000..cef8e5f --- /dev/null +++ b/visualization/include/balsa/visualization/vulkan/vulkan_image_drawable.hpp @@ -0,0 +1,106 @@ +#if !defined(BALSA_VISUALIZATION_VULKAN_VULKAN_IMAGE_DRAWABLE_HPP) +#define BALSA_VISUALIZATION_VULKAN_VULKAN_IMAGE_DRAWABLE_HPP + +#include +#include +#include + +#include "balsa/visualization/vulkan/buffer.hpp" +#include "balsa/visualization/vulkan/drawable.hpp" +#include "balsa/visualization/vulkan/texture.hpp" + +namespace balsa::visualization::vulkan { + +class Film; +class ImagePipelineManager; + +// ── VulkanImageDrawable ───────────────────────────────────────────── +// +// Scene-graph-aware bridge feature that connects an ImageData feature +// (CPU-side pixel buffer) to the Vulkan image rendering pipeline. +// +// Lives as a feature on the same Object as an ImageData feature. Owns +// the GPU resources (VulkanTexture, UBO buffers, descriptor sets) and +// syncs from ImageData using version tracking. +// +// Lifecycle: +// 1. Attach to an Object that already has an ImageData feature +// 2. Call init(film) once Vulkan is ready +// 3. Each frame: draw(camera, film) — syncs texture if dirty, +// updates UBOs, and issues a fullscreen triangle draw +// 4. On teardown: release() or let destructor handle it +// +// Thread-safety: NOT thread-safe — call only from the rendering thread. + +class VulkanImageDrawable : public VulkanDrawable { + public: + // Construct with the DrawableGroup this drawable belongs to, and + // a reference to the ImagePipelineManager that owns descriptor + // layouts / pipeline cache. + VulkanImageDrawable(scene_graph::DrawableGroup &group, + ImagePipelineManager &manager); + + ~VulkanImageDrawable() override; + + // Non-copyable (base class already), non-movable (registered). + VulkanImageDrawable(VulkanImageDrawable &&) = delete; + auto operator=(VulkanImageDrawable &&) -> VulkanImageDrawable & = delete; + + // ── Lifecycle ─────────────────────────────────────────────────── + + // Allocate UBO buffers and descriptor sets (one per concurrent + // frame slot). Must be called once after the Film is available. + auto init(Film &film) -> void; + + // Release all GPU resources. Safe to call multiple times. + auto release() -> void; + + auto is_initialized() const -> bool { return _initialized; } + + // ── VulkanDrawable interface ──────────────────────────────────── + + // Draw this image with the given camera. Syncs from ImageData if + // dirty, updates UBOs with the MVP and tone-mapping params, then + // issues a fullscreen triangle draw. + auto draw(const scene_graph::Camera &cam, Film &film) -> void override; + + // ── MVP override ──────────────────────────────────────────────── + + // Set a custom MVP matrix for 2D viewing (orthographic pan/zoom). + // When set, this overrides the camera-derived MVP. + auto set_mvp_override(const scene_graph::Mat4f &mvp) -> void { + _mvp_override = mvp; + _has_mvp_override = true; + } + auto clear_mvp_override() -> void { _has_mvp_override = false; } + + private: + // Sync GPU texture from the sibling ImageData feature if its + // version has changed since our last sync. + auto sync_from_image_data(Film &film) -> void; + + // Upload TransformUBO (MVP) and ImageParamsUBO (tone mapping). + auto update_ubos(const scene_graph::Camera &cam, Film &film) -> void; + + // Issue fullscreen triangle draw commands. + auto record_draw_commands(Film &film) -> void; + + ImagePipelineManager *_manager; + VulkanTexture _texture; + + // Per-frame UBO buffers and descriptor sets — one per concurrent + // frame slot. Sized by film.concurrent_frame_count() in init(). + std::vector _transform_ubos; + std::vector _params_ubos; + std::vector _descriptor_sets; + + scene_graph::Mat4f _mvp_override; + bool _has_mvp_override = false; + + uint64_t _synced_version = 0; + bool _initialized = false; +}; + +} // namespace balsa::visualization::vulkan + +#endif diff --git a/visualization/meson.build b/visualization/meson.build index b8462f6..8242579 100644 --- a/visualization/meson.build +++ b/visualization/meson.build @@ -33,8 +33,7 @@ if get_option('imgui') endif endif -colormap_proj = subproject('colormap_shaders') -colormap_shaders_dep = colormap_proj.get_variable('colormap_shaders_dep') +colormap_shaders_dep = dependency('colormap_shaders') # Public deps: propagated to consumers via declare_dependency(). visualization_public_deps = [vulkan_dep, shaderc_dep, dl_lib, colormap_shaders_dep] @@ -58,6 +57,12 @@ visualization_sources = [ 'src/scene_graph/MeshData.cpp', 'src/scene_graph/BVHData.cpp', 'src/scene_graph/DrawableGroup.cpp', + 'src/scene_graph/ImageData.cpp', + 'src/vulkan/texture.cpp', + 'src/vulkan/image_pipeline.cpp', + 'src/vulkan/vulkan_image_drawable.cpp', + 'src/vulkan/image_scene.cpp', + 'src/image_io.cpp', ] if get_option('qt') @@ -102,12 +107,15 @@ if get_option('imgui') visualization_public_deps += imgui_dep visualization_sources += 'src/vulkan/imgui_integration.cpp' visualization_sources += 'src/vulkan/imgui/mesh_controls_panel.cpp' + visualization_sources += 'src/vulkan/imgui/image_controls_panel.cpp' visualization_sources += imgui_backend_sources endif visualization_public_deps += core_dep visualization_public_deps += geometry_dep -visualization_public_deps += quiver_dep +if get_option('quiver') + visualization_public_deps += quiver_dep +endif include_dir = [include_directories('include')] visualization_lib = library('balsaVisualization', visualization_sources, @@ -116,6 +124,7 @@ visualization_lib = library('balsaVisualization', visualization_sources, # For consumers: propagate public deps (including imgui shared library). visualization_dep = declare_dependency(link_with: visualization_lib, dependencies: visualization_public_deps, include_directories: include_dir) +meson.override_dependency('balsaVisualization', visualization_dep) if get_option('testing') subdir('tests') diff --git a/visualization/resources/glsl/glsl.qrc b/visualization/resources/glsl/glsl.qrc index 81f9d93..8bbcd5d 100644 --- a/visualization/resources/glsl/glsl.qrc +++ b/visualization/resources/glsl/glsl.qrc @@ -6,5 +6,7 @@ triangle.frag mesh.vert mesh.frag + image.vert + image.frag diff --git a/visualization/resources/glsl/image.frag b/visualization/resources/glsl/image.frag new file mode 100644 index 0000000..fcaffc1 --- /dev/null +++ b/visualization/resources/glsl/image.frag @@ -0,0 +1,35 @@ +#version 460 core + +layout(binding = 1) uniform ImageParams { + vec4 tone_params; // x=exposure, y=gamma, z=channel_mode, w=unused + vec4 image_size; // x=width, y=height, z=1/width, w=1/height +}; + +layout(binding = 2) uniform sampler2D u_image; + +layout(location = 0) in vec2 v_uv; +layout(location = 0) out vec4 outColor; + +void main() { + vec4 texel = texture(u_image, v_uv); + + // Exposure (EV stops) + float exposure = tone_params.x; + texel.rgb *= exp2(exposure); + + // Gamma correction + float gamma = tone_params.y; + texel.rgb = pow(max(texel.rgb, vec3(0.0)), vec3(1.0 / gamma)); + + // Channel isolation + int mode = int(tone_params.z); + if (mode == 1) outColor = vec4(texel.rrr, 1.0); // Red + else if (mode == 2) outColor = vec4(texel.ggg, 1.0); // Green + else if (mode == 3) outColor = vec4(texel.bbb, 1.0); // Blue + else if (mode == 4) outColor = vec4(texel.aaa, 1.0); // Alpha + else if (mode == 5) { // Luminance + float lum = dot(texel.rgb, vec3(0.2126, 0.7152, 0.0722)); + outColor = vec4(vec3(lum), 1.0); + } + else outColor = vec4(texel.rgb, texel.a); // RGBA +} diff --git a/visualization/resources/glsl/image.vert b/visualization/resources/glsl/image.vert new file mode 100644 index 0000000..6445a92 --- /dev/null +++ b/visualization/resources/glsl/image.vert @@ -0,0 +1,26 @@ +#version 460 core + +layout(binding = 0) uniform Transforms { + mat4 mvp; +}; + +// Fullscreen triangle technique: 3 vertices, no vertex buffer needed. +// Vertex IDs 0, 1, 2 produce a triangle that covers the entire clip +// space [-1, 1]^2. UVs map to [0, 1]^2 for texture sampling. +// +// When an MVP is provided (non-identity), the quad corners are +// transformed for pan/zoom support. + +layout(location = 0) out vec2 v_uv; + +void main() { + // Positions for a fullscreen triangle covering [-1, 1]^2: + // Vertex 0: (-1, -1) + // Vertex 1: ( 3, -1) + // Vertex 2: (-1, 3) + vec2 pos = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2); + v_uv = pos * 0.5; + // Map from [0,2] -> [-1,1] + vec2 ndc = pos - 1.0; + gl_Position = mvp * vec4(ndc, 0.0, 1.0); +} diff --git a/visualization/src/image_io.cpp b/visualization/src/image_io.cpp new file mode 100644 index 0000000..88c3da1 --- /dev/null +++ b/visualization/src/image_io.cpp @@ -0,0 +1,136 @@ +#include "balsa/visualization/image_io.hpp" + +#include +#include +#include +#include + +namespace balsa::visualization { + +auto error_string(ImageIOError err) -> std::string_view { + switch (err) { + case ImageIOError::FileNotFound: + return "File not found"; + case ImageIOError::InvalidFormat: + return "Invalid image format"; + case ImageIOError::ReadError: + return "Read error"; + case ImageIOError::WriteError: + return "Write error"; + } + return "Unknown error"; +} + +// ── PPM P6 reader ────────────────────────────────────────────────── +// +// P6 binary format: +// P6\n +// \n +// \n +// +// +// Comments (lines starting with '#') may appear between the header +// fields. + +auto load_ppm(const std::string &path) + -> std::expected { + std::FILE *f = std::fopen(path.c_str(), "rb"); + if (!f) { return std::unexpected(ImageIOError::FileNotFound); } + + // Helper to skip comment lines. + auto skip_comments = [&]() { + int c; + while ((c = std::fgetc(f)) == '#') { + // Skip to end of line. + while ((c = std::fgetc(f)) != '\n' && c != EOF) {} + } + if (c != EOF) std::ungetc(c, f); + }; + + // Read magic number. + char magic[3] = {}; + if (std::fread(magic, 1, 2, f) != 2 || magic[0] != 'P' || magic[1] != '6') { + std::fclose(f); + return std::unexpected(ImageIOError::InvalidFormat); + } + + // Skip whitespace + comments. + skip_comments(); + + // Read width and height. + int width = 0, height = 0; + if (std::fscanf(f, "%d %d", &width, &height) != 2 || width <= 0 + || height <= 0) { + std::fclose(f); + return std::unexpected(ImageIOError::InvalidFormat); + } + + skip_comments(); + + // Read maxval. + int maxval = 0; + if (std::fscanf(f, "%d", &maxval) != 1 || maxval <= 0 || maxval > 255) { + std::fclose(f); + return std::unexpected(ImageIOError::InvalidFormat); + } + + // Single whitespace character after maxval. + std::fgetc(f); + + // Read RGB data. + size_t pixel_count = static_cast(width) * height; + std::vector rgb(pixel_count * 3); + if (std::fread(rgb.data(), 1, rgb.size(), f) != rgb.size()) { + std::fclose(f); + return std::unexpected(ImageIOError::ReadError); + } + + std::fclose(f); + + // Convert RGB -> RGBA. + ImageBuffer result; + result.width = static_cast(width); + result.height = static_cast(height); + result.pixels.resize(pixel_count * 4); + + for (size_t i = 0; i < pixel_count; ++i) { + result.pixels[i * 4 + 0] = rgb[i * 3 + 0]; + result.pixels[i * 4 + 1] = rgb[i * 3 + 1]; + result.pixels[i * 4 + 2] = rgb[i * 3 + 2]; + result.pixels[i * 4 + 3] = 255; + } + + return result; +} + +// ── PPM P6 writer ────────────────────────────────────────────────── + +auto save_ppm(const std::string &path, + uint32_t width, + uint32_t height, + const uint8_t *rgba_pixels) -> std::expected { + std::FILE *f = std::fopen(path.c_str(), "wb"); + if (!f) { return std::unexpected(ImageIOError::WriteError); } + + // Write header. + std::fprintf(f, "P6\n%u %u\n255\n", width, height); + + // Write RGB data (drop alpha). + size_t pixel_count = static_cast(width) * height; + std::vector rgb(pixel_count * 3); + for (size_t i = 0; i < pixel_count; ++i) { + rgb[i * 3 + 0] = rgba_pixels[i * 4 + 0]; + rgb[i * 3 + 1] = rgba_pixels[i * 4 + 1]; + rgb[i * 3 + 2] = rgba_pixels[i * 4 + 2]; + } + + if (std::fwrite(rgb.data(), 1, rgb.size(), f) != rgb.size()) { + std::fclose(f); + return std::unexpected(ImageIOError::WriteError); + } + + std::fclose(f); + return {}; +} + +} // namespace balsa::visualization diff --git a/visualization/src/qt/mesh_controls_widget.cpp b/visualization/src/qt/mesh_controls_widget.cpp index 129e02d..9ddb608 100644 --- a/visualization/src/qt/mesh_controls_widget.cpp +++ b/visualization/src/qt/mesh_controls_widget.cpp @@ -6,13 +6,13 @@ // that transitively include TBB (via quiver), then restore it. #undef emit -#include "balsa/visualization/vulkan/mesh_scene.hpp" -#include "balsa/visualization/vulkan/mesh_render_state.hpp" -#include "balsa/visualization/colormap_list.hpp" -#include "balsa/scene_graph/Object.hpp" -#include "balsa/scene_graph/MeshData.hpp" -#include "balsa/scene_graph/Light.hpp" #include "balsa/scene_graph/BVHData.hpp" +#include "balsa/scene_graph/Light.hpp" +#include "balsa/scene_graph/MeshData.hpp" +#include "balsa/scene_graph/Object.hpp" +#include "balsa/visualization/colormap_list.hpp" +#include "balsa/visualization/vulkan/mesh_render_state.hpp" +#include "balsa/visualization/vulkan/mesh_scene.hpp" // Restore Qt's emit macro (expands to nothing, but needed for readability). #define emit @@ -32,14 +32,16 @@ #include #include +#include + namespace balsa::visualization::qt { namespace vulkan = ::balsa::visualization::vulkan; namespace sg = ::balsa::scene_graph; -using visualization::k_colormap_names; -using visualization::k_colormap_count; using visualization::find_colormap_index; +using visualization::k_colormap_count; +using visualization::k_colormap_names; // ── Helper: create a labeled slider ───────────────────────────────── @@ -52,15 +54,22 @@ static QSlider *make_slider(int min, int max, int value, QWidget *parent) { // ── Helper: set QPushButton background to a color ─────────────────── -static void set_button_color(QPushButton *btn, float r, float g, float b, float a) { +static void + set_button_color(QPushButton *btn, float r, float g, float b, float a) { QColor c = QColor::fromRgbF(r, g, b, a); btn->setStyleSheet( - QStringLiteral("background-color: %1; border: 1px solid gray;").arg(c.name(QColor::HexArgb))); + QStringLiteral("background-color: %1; border: 1px solid gray;") + .arg(c.name(QColor::HexArgb))); } // ── Helper: make a labeled double spin box ────────────────────────── -static QDoubleSpinBox *make_spin(const char *label, QWidget *parent, QHBoxLayout *row, double min, double max, double step) { +static QDoubleSpinBox *make_spin(const char *label, + QWidget *parent, + QHBoxLayout *row, + double min, + double max, + double step) { row->addWidget(new QLabel(label, parent)); auto *spin = new QDoubleSpinBox(parent); spin->setRange(min, max); @@ -73,8 +82,7 @@ static QDoubleSpinBox *make_spin(const char *label, QWidget *parent, QHBoxLayout // ── Construction / Destruction ─────────────────────────────────────── -MeshControlsWidget::MeshControlsWidget(QWidget *parent) - : QWidget(parent) { +MeshControlsWidget::MeshControlsWidget(QWidget *parent) : QWidget(parent) { build_ui(); } @@ -82,13 +90,15 @@ MeshControlsWidget::~MeshControlsWidget() = default; // ── set_scene / set_selected_object ───────────────────────────────── -void MeshControlsWidget::set_scene(::balsa::visualization::vulkan::MeshScene *scene) { +void MeshControlsWidget::set_scene( + ::balsa::visualization::vulkan::MeshScene *scene) { _scene = scene; _selected = nullptr; sync_from_state(); } -void MeshControlsWidget::set_selected_object(::balsa::scene_graph::Object *obj) { +void MeshControlsWidget::set_selected_object( + ::balsa::scene_graph::Object *obj) { _selected = obj; sync_from_state(); } @@ -105,11 +115,20 @@ void MeshControlsWidget::build_ui() { // Unicode icon characters for each tab. // Using simple geometric / symbolic characters. - _tab_object = _tabs->addTab(build_object_page(), QStringLiteral("\u25A2"), QStringLiteral("Object"));// ▢ - _tab_layers = _tabs->addTab(build_layers_page(), QStringLiteral("\u2630"), QStringLiteral("Layers"));// ☰ - _tab_material = _tabs->addTab(build_material_page(), QStringLiteral("\u25CF"), QStringLiteral("Material"));// ● - _tab_bvh = _tabs->addTab(build_bvh_page(), QStringLiteral("\u2592"), QStringLiteral("BVH"));// ▒ - _tab_lighting = _tabs->addTab(build_lighting_page(), QStringLiteral("\u2600"), QStringLiteral("Lighting"));// ☀ + _tab_object = _tabs->addTab(build_object_page(), + QStringLiteral("\u25A2"), + QStringLiteral("Object")); // ▢ + _tab_layers = _tabs->addTab(build_layers_page(), + QStringLiteral("\u2630"), + QStringLiteral("Layers")); // ☰ + _tab_material = _tabs->addTab(build_material_page(), + QStringLiteral("\u25CF"), + QStringLiteral("Material")); // ● + _tab_bvh = _tabs->addTab( + build_bvh_page(), QStringLiteral("\u2592"), QStringLiteral("BVH")); // ▒ + _tab_lighting = _tabs->addTab(build_lighting_page(), + QStringLiteral("\u2600"), + QStringLiteral("Lighting")); // ☀ root_layout->addWidget(_tabs, 1); } @@ -130,7 +149,8 @@ QWidget *MeshControlsWidget::build_object_page() { return page; } -void MeshControlsWidget::build_object_info(QWidget *parent, QBoxLayout *layout) { +void MeshControlsWidget::build_object_info(QWidget *parent, + QBoxLayout *layout) { // Name auto *name_row = new QHBoxLayout; name_row->addWidget(new QLabel("Name:", parent)); @@ -152,11 +172,18 @@ void MeshControlsWidget::build_object_info(QWidget *parent, QBoxLayout *layout) layout->addWidget(_edge_count_label); layout->addWidget(_attribute_label); - connect(_name_edit, &QLineEdit::textChanged, this, &MeshControlsWidget::on_name_edited); - connect(_visible_check, &QCheckBox::toggled, this, &MeshControlsWidget::on_visibility_changed); + connect(_name_edit, + &QLineEdit::textChanged, + this, + &MeshControlsWidget::on_name_edited); + connect(_visible_check, + &QCheckBox::toggled, + this, + &MeshControlsWidget::on_visibility_changed); } -void MeshControlsWidget::build_transform_section(QWidget *parent, QBoxLayout *layout) { +void MeshControlsWidget::build_transform_section(QWidget *parent, + QBoxLayout *layout) { _transform_group = new QGroupBox("Transform", parent); _transform_group->setEnabled(false); auto *grp_layout = new QVBoxLayout(_transform_group); @@ -195,16 +222,28 @@ void MeshControlsWidget::build_transform_section(QWidget *parent, QBoxLayout *la layout->addWidget(_transform_group); // Connect spin boxes - for (auto *spin : { _tx, _ty, _tz }) { - connect(spin, QOverload::of(&QDoubleSpinBox::valueChanged), this, &MeshControlsWidget::on_translation_changed); + for (auto *spin : {_tx, _ty, _tz}) { + connect(spin, + QOverload::of(&QDoubleSpinBox::valueChanged), + this, + &MeshControlsWidget::on_translation_changed); } - for (auto *spin : { _rx, _ry, _rz }) { - connect(spin, QOverload::of(&QDoubleSpinBox::valueChanged), this, &MeshControlsWidget::on_rotation_changed); + for (auto *spin : {_rx, _ry, _rz}) { + connect(spin, + QOverload::of(&QDoubleSpinBox::valueChanged), + this, + &MeshControlsWidget::on_rotation_changed); } - for (auto *spin : { _sx, _sy, _sz }) { - connect(spin, QOverload::of(&QDoubleSpinBox::valueChanged), this, &MeshControlsWidget::on_scale_changed); + for (auto *spin : {_sx, _sy, _sz}) { + connect(spin, + QOverload::of(&QDoubleSpinBox::valueChanged), + this, + &MeshControlsWidget::on_scale_changed); } - connect(_reset_transform_btn, &QPushButton::clicked, this, &MeshControlsWidget::on_reset_transform); + connect(_reset_transform_btn, + &QPushButton::clicked, + this, + &MeshControlsWidget::on_reset_transform); } // ═════════════════════════════════════════════════════════════════════ @@ -219,12 +258,14 @@ QWidget *MeshControlsWidget::build_layers_page() { build_layers_group(page, layout); build_render_state_group(page, layout); build_color_group(page, layout); + build_attribute_bindings_group(page, layout); layout->addStretch(); return page; } -void MeshControlsWidget::build_layers_group(QWidget *parent, QBoxLayout *layout) { +void MeshControlsWidget::build_layers_group(QWidget *parent, + QBoxLayout *layout) { auto *group = new QGroupBox("Render Layers", parent); auto *grp_layout = new QVBoxLayout(group); @@ -233,7 +274,8 @@ void MeshControlsWidget::build_layers_group(QWidget *parent, QBoxLayout *layout) _solid_enabled_check = new QCheckBox("Solid", group); solid_row->addWidget(_solid_enabled_check); _solid_color_button = new QPushButton(group); - _solid_color_button->setVisible(false);// unused: solid color comes from Color section + _solid_color_button->setVisible( + false); // unused: solid color comes from Color section auto *solid_hint = new QLabel("(color: see Color section)", group); solid_hint->setStyleSheet("color: gray; font-size: 10px;"); solid_row->addWidget(solid_hint); @@ -254,10 +296,12 @@ void MeshControlsWidget::build_layers_group(QWidget *parent, QBoxLayout *layout) // Wireframe details (width slider) — visible only when enabled _wireframe_details_container = new QWidget(group); auto *wire_details_layout = new QHBoxLayout(_wireframe_details_container); - wire_details_layout->setContentsMargins(20, 0, 0, 0);// indent - _wireframe_width_label = new QLabel("Width: 1.5", _wireframe_details_container); + wire_details_layout->setContentsMargins(20, 0, 0, 0); // indent + _wireframe_width_label = + new QLabel("Width: 1.5", _wireframe_details_container); wire_details_layout->addWidget(_wireframe_width_label); - _wireframe_width_slider = make_slider(5, 50, 15, _wireframe_details_container);// *10 scale + _wireframe_width_slider = + make_slider(5, 50, 15, _wireframe_details_container); // *10 scale wire_details_layout->addWidget(_wireframe_width_slider); grp_layout->addWidget(_wireframe_details_container); @@ -275,27 +319,53 @@ void MeshControlsWidget::build_layers_group(QWidget *parent, QBoxLayout *layout) // Point details (size slider) — visible only when enabled _points_details_container = new QWidget(group); auto *point_details_layout = new QHBoxLayout(_points_details_container); - point_details_layout->setContentsMargins(20, 0, 0, 0);// indent + point_details_layout->setContentsMargins(20, 0, 0, 0); // indent _point_size_label = new QLabel("Size: 3.0", _points_details_container); point_details_layout->addWidget(_point_size_label); - _point_size_slider = make_slider(1, 200, 30, _points_details_container);// *10 scale + _point_size_slider = + make_slider(1, 200, 30, _points_details_container); // *10 scale point_details_layout->addWidget(_point_size_slider); grp_layout->addWidget(_points_details_container); layout->addWidget(group); // Connections - connect(_solid_enabled_check, &QCheckBox::toggled, this, &MeshControlsWidget::on_solid_enabled_changed); - connect(_solid_color_button, &QPushButton::clicked, this, &MeshControlsWidget::on_solid_color_clicked); - connect(_wireframe_enabled_check, &QCheckBox::toggled, this, &MeshControlsWidget::on_wireframe_enabled_changed); - connect(_wireframe_color_button, &QPushButton::clicked, this, &MeshControlsWidget::on_wireframe_color_clicked); - connect(_wireframe_width_slider, &QSlider::valueChanged, this, &MeshControlsWidget::on_wireframe_width_changed); - connect(_points_enabled_check, &QCheckBox::toggled, this, &MeshControlsWidget::on_points_enabled_changed); - connect(_point_color_button, &QPushButton::clicked, this, &MeshControlsWidget::on_point_color_clicked); - connect(_point_size_slider, &QSlider::valueChanged, this, &MeshControlsWidget::on_point_size_changed); + connect(_solid_enabled_check, + &QCheckBox::toggled, + this, + &MeshControlsWidget::on_solid_enabled_changed); + connect(_solid_color_button, + &QPushButton::clicked, + this, + &MeshControlsWidget::on_solid_color_clicked); + connect(_wireframe_enabled_check, + &QCheckBox::toggled, + this, + &MeshControlsWidget::on_wireframe_enabled_changed); + connect(_wireframe_color_button, + &QPushButton::clicked, + this, + &MeshControlsWidget::on_wireframe_color_clicked); + connect(_wireframe_width_slider, + &QSlider::valueChanged, + this, + &MeshControlsWidget::on_wireframe_width_changed); + connect(_points_enabled_check, + &QCheckBox::toggled, + this, + &MeshControlsWidget::on_points_enabled_changed); + connect(_point_color_button, + &QPushButton::clicked, + this, + &MeshControlsWidget::on_point_color_clicked); + connect(_point_size_slider, + &QSlider::valueChanged, + this, + &MeshControlsWidget::on_point_size_changed); } -void MeshControlsWidget::build_render_state_group(QWidget *parent, QBoxLayout *layout) { +void MeshControlsWidget::build_render_state_group(QWidget *parent, + QBoxLayout *layout) { auto *group = new QGroupBox("Shading && Rendering", parent); auto *grp_layout = new QVBoxLayout(group); @@ -303,7 +373,8 @@ void MeshControlsWidget::build_render_state_group(QWidget *parent, QBoxLayout *l auto *normal_row = new QHBoxLayout; normal_row->addWidget(new QLabel("Normals:", group)); _normal_source_combo = new QComboBox(group); - _normal_source_combo->addItems({ "From Attribute", "Computed (Flat)", "None (Unlit)" }); + _normal_source_combo->addItems( + {"From Attribute", "Computed (Flat)", "None (Unlit)"}); normal_row->addWidget(_normal_source_combo); grp_layout->addLayout(normal_row); @@ -316,12 +387,13 @@ void MeshControlsWidget::build_render_state_group(QWidget *parent, QBoxLayout *l auto *shading_row = new QHBoxLayout; shading_row->addWidget(new QLabel("Shading:", _shading_details_container)); _shading_combo = new QComboBox(_shading_details_container); - _shading_combo->addItems({ "Flat", "Gouraud", "Phong" }); + _shading_combo->addItems({"Flat", "Gouraud", "Phong"}); shading_row->addWidget(_shading_combo); shading_layout->addLayout(shading_row); // Two-sided - _two_sided_check = new QCheckBox("Two-Sided Lighting", _shading_details_container); + _two_sided_check = + new QCheckBox("Two-Sided Lighting", _shading_details_container); shading_layout->addWidget(_two_sided_check); grp_layout->addWidget(_shading_details_container); @@ -330,19 +402,33 @@ void MeshControlsWidget::build_render_state_group(QWidget *parent, QBoxLayout *l auto *cull_row = new QHBoxLayout; cull_row->addWidget(new QLabel("Face Culling:", group)); _cull_mode_combo = new QComboBox(group); - _cull_mode_combo->addItems({ "None (Show All)", "Back (Exterior)", "Front (Interior)" }); + _cull_mode_combo->addItems( + {"None (Show All)", "Back (Exterior)", "Front (Interior)"}); cull_row->addWidget(_cull_mode_combo); grp_layout->addLayout(cull_row); layout->addWidget(group); - connect(_normal_source_combo, QOverload::of(&QComboBox::currentIndexChanged), this, &MeshControlsWidget::on_normal_source_changed); - connect(_shading_combo, QOverload::of(&QComboBox::currentIndexChanged), this, &MeshControlsWidget::on_shading_changed); - connect(_two_sided_check, &QCheckBox::toggled, this, &MeshControlsWidget::on_two_sided_changed); - connect(_cull_mode_combo, QOverload::of(&QComboBox::currentIndexChanged), this, &MeshControlsWidget::on_cull_mode_changed); + connect(_normal_source_combo, + QOverload::of(&QComboBox::currentIndexChanged), + this, + &MeshControlsWidget::on_normal_source_changed); + connect(_shading_combo, + QOverload::of(&QComboBox::currentIndexChanged), + this, + &MeshControlsWidget::on_shading_changed); + connect(_two_sided_check, + &QCheckBox::toggled, + this, + &MeshControlsWidget::on_two_sided_changed); + connect(_cull_mode_combo, + QOverload::of(&QComboBox::currentIndexChanged), + this, + &MeshControlsWidget::on_cull_mode_changed); } -void MeshControlsWidget::build_color_group(QWidget *parent, QBoxLayout *layout) { +void MeshControlsWidget::build_color_group(QWidget *parent, + QBoxLayout *layout) { auto *group = new QGroupBox("Color", parent); auto *grp_layout = new QVBoxLayout(group); @@ -350,7 +436,8 @@ void MeshControlsWidget::build_color_group(QWidget *parent, QBoxLayout *layout) auto *src_row = new QHBoxLayout; src_row->addWidget(new QLabel("Source:", group)); _color_source_combo = new QComboBox(group); - _color_source_combo->addItems({ "Uniform Color", "Per-Vertex Color", "Scalar Field" }); + _color_source_combo->addItems( + {"Uniform Color", "Per-Vertex Color", "Scalar Field"}); src_row->addWidget(_color_source_combo); grp_layout->addLayout(src_row); @@ -409,20 +496,104 @@ void MeshControlsWidget::build_color_group(QWidget *parent, QBoxLayout *layout) max_row->addWidget(_scalar_max_spin); sf_layout->addLayout(max_row); - _scalar_range_reset_button = new QPushButton("Reset Range", _scalar_field_container); + _scalar_range_reset_button = + new QPushButton("Reset Range", _scalar_field_container); sf_layout->addWidget(_scalar_range_reset_button); grp_layout->addWidget(_scalar_field_container); layout->addWidget(group); - connect(_color_source_combo, QOverload::of(&QComboBox::currentIndexChanged), this, &MeshControlsWidget::on_color_source_changed); - connect(_uniform_color_button, &QPushButton::clicked, this, &MeshControlsWidget::on_uniform_color_clicked); - connect(_colormap_combo, QOverload::of(&QComboBox::currentIndexChanged), this, &MeshControlsWidget::on_colormap_changed); - connect(_colormap_custom_edit, &QLineEdit::editingFinished, this, &MeshControlsWidget::on_colormap_custom_edited); - connect(_scalar_min_spin, QOverload::of(&QDoubleSpinBox::valueChanged), this, &MeshControlsWidget::on_scalar_min_changed); - connect(_scalar_max_spin, QOverload::of(&QDoubleSpinBox::valueChanged), this, &MeshControlsWidget::on_scalar_max_changed); - connect(_scalar_range_reset_button, &QPushButton::clicked, this, &MeshControlsWidget::on_scalar_range_reset); + connect(_color_source_combo, + QOverload::of(&QComboBox::currentIndexChanged), + this, + &MeshControlsWidget::on_color_source_changed); + connect(_uniform_color_button, + &QPushButton::clicked, + this, + &MeshControlsWidget::on_uniform_color_clicked); + connect(_colormap_combo, + QOverload::of(&QComboBox::currentIndexChanged), + this, + &MeshControlsWidget::on_colormap_changed); + connect(_colormap_custom_edit, + &QLineEdit::editingFinished, + this, + &MeshControlsWidget::on_colormap_custom_edited); + connect(_scalar_min_spin, + QOverload::of(&QDoubleSpinBox::valueChanged), + this, + &MeshControlsWidget::on_scalar_min_changed); + connect(_scalar_max_spin, + QOverload::of(&QDoubleSpinBox::valueChanged), + this, + &MeshControlsWidget::on_scalar_max_changed); + connect(_scalar_range_reset_button, + &QPushButton::clicked, + this, + &MeshControlsWidget::on_scalar_range_reset); +} + +// ═════════════════════════════════════════════════════════════════════ +// Attribute Bindings group +// ═════════════════════════════════════════════════════════════════════ + +void MeshControlsWidget::build_attribute_bindings_group(QWidget *parent, + QBoxLayout *layout) { + _attr_bindings_group = new QGroupBox("Attribute Bindings", parent); + auto *grp_layout = new QVBoxLayout(_attr_bindings_group); + + // Position attribute combo + auto *pos_row = new QHBoxLayout; + pos_row->addWidget(new QLabel("Position:", _attr_bindings_group)); + _position_attr_combo = new QComboBox(_attr_bindings_group); + pos_row->addWidget(_position_attr_combo); + grp_layout->addLayout(pos_row); + + // Normal attribute combo + auto *norm_row = new QHBoxLayout; + norm_row->addWidget(new QLabel("Normal:", _attr_bindings_group)); + _normal_attr_combo = new QComboBox(_attr_bindings_group); + norm_row->addWidget(_normal_attr_combo); + grp_layout->addLayout(norm_row); + + // Scalar attribute combo + auto *scalar_row = new QHBoxLayout; + scalar_row->addWidget(new QLabel("Scalar:", _attr_bindings_group)); + _scalar_attr_combo = new QComboBox(_attr_bindings_group); + scalar_row->addWidget(_scalar_attr_combo); + grp_layout->addLayout(scalar_row); + + // Scalar component selector (hidden when scalar not bound or 1-component) + _scalar_component_container = new QWidget(_attr_bindings_group); + auto *comp_layout = new QHBoxLayout(_scalar_component_container); + comp_layout->setContentsMargins(20, 0, 0, 0); // indent + comp_layout->addWidget( + new QLabel("Component:", _scalar_component_container)); + _scalar_component_combo = new QComboBox(_scalar_component_container); + comp_layout->addWidget(_scalar_component_combo); + grp_layout->addWidget(_scalar_component_container); + _scalar_component_container->setVisible(false); + + layout->addWidget(_attr_bindings_group); + + // Connections + connect(_position_attr_combo, + QOverload::of(&QComboBox::currentIndexChanged), + this, + &MeshControlsWidget::on_position_attr_changed); + connect(_normal_attr_combo, + QOverload::of(&QComboBox::currentIndexChanged), + this, + &MeshControlsWidget::on_normal_attr_changed); + connect(_scalar_attr_combo, + QOverload::of(&QComboBox::currentIndexChanged), + this, + &MeshControlsWidget::on_scalar_attr_changed); + connect(_scalar_component_combo, + QOverload::of(&QComboBox::currentIndexChanged), + this, + &MeshControlsWidget::on_scalar_component_changed); } // ═════════════════════════════════════════════════════════════════════ @@ -437,8 +608,10 @@ QWidget *MeshControlsWidget::build_material_page() { build_material_group(page, layout); // Placeholder for future named material system - auto *placeholder = new QLabel("Named materials coming in a future update.", page); - placeholder->setStyleSheet("color: gray; font-size: 10px; font-style: italic;"); + auto *placeholder = + new QLabel("Named materials coming in a future update.", page); + placeholder->setStyleSheet( + "color: gray; font-size: 10px; font-style: italic;"); placeholder->setWordWrap(true); layout->addWidget(placeholder); @@ -446,7 +619,8 @@ QWidget *MeshControlsWidget::build_material_page() { return page; } -void MeshControlsWidget::build_material_group(QWidget *parent, QBoxLayout *layout) { +void MeshControlsWidget::build_material_group(QWidget *parent, + QBoxLayout *layout) { auto *group = new QGroupBox("Material Response", parent); group->setToolTip("How this mesh responds to scene light"); auto *grp_layout = new QVBoxLayout(group); @@ -459,20 +633,24 @@ void MeshControlsWidget::build_material_group(QWidget *parent, QBoxLayout *layou // Ambient auto *amb_row = new QHBoxLayout; _material_ambient_label = new QLabel("Ambient: 0.15", group); - _material_ambient_label->setToolTip("Base light the mesh receives regardless of light direction"); + _material_ambient_label->setToolTip( + "Base light the mesh receives regardless of light direction"); amb_row->addWidget(_material_ambient_label); _material_ambient_slider = make_slider(0, 100, 15, group); - _material_ambient_slider->setToolTip("Base light the mesh receives regardless of light direction"); + _material_ambient_slider->setToolTip( + "Base light the mesh receives regardless of light direction"); amb_row->addWidget(_material_ambient_slider); grp_layout->addLayout(amb_row); // Diffuse auto *diff_row = new QHBoxLayout; _material_diffuse_label = new QLabel("Diffuse: 1.00", group); - _material_diffuse_label->setToolTip("Intensity of the diffuse lighting response"); + _material_diffuse_label->setToolTip( + "Intensity of the diffuse lighting response"); diff_row->addWidget(_material_diffuse_label); _material_diffuse_slider = make_slider(0, 200, 100, group); - _material_diffuse_slider->setToolTip("Intensity of the diffuse lighting response"); + _material_diffuse_slider->setToolTip( + "Intensity of the diffuse lighting response"); diff_row->addWidget(_material_diffuse_slider); grp_layout->addLayout(diff_row); @@ -482,26 +660,41 @@ void MeshControlsWidget::build_material_group(QWidget *parent, QBoxLayout *layou _material_specular_label->setToolTip("Intensity of the specular highlight"); spec_row->addWidget(_material_specular_label); _material_specular_slider = make_slider(0, 200, 50, group); - _material_specular_slider->setToolTip("Intensity of the specular highlight"); + _material_specular_slider->setToolTip( + "Intensity of the specular highlight"); spec_row->addWidget(_material_specular_slider); grp_layout->addLayout(spec_row); // Shininess auto *shin_row = new QHBoxLayout; _material_shininess_label = new QLabel("Shininess: 32", group); - _material_shininess_label->setToolTip("Sharpness of the specular highlight (higher = tighter)"); + _material_shininess_label->setToolTip( + "Sharpness of the specular highlight (higher = tighter)"); shin_row->addWidget(_material_shininess_label); _material_shininess_slider = make_slider(1, 256, 32, group); - _material_shininess_slider->setToolTip("Sharpness of the specular highlight (higher = tighter)"); + _material_shininess_slider->setToolTip( + "Sharpness of the specular highlight (higher = tighter)"); shin_row->addWidget(_material_shininess_slider); grp_layout->addLayout(shin_row); layout->addWidget(group); - connect(_material_ambient_slider, &QSlider::valueChanged, this, &MeshControlsWidget::on_material_ambient_changed); - connect(_material_diffuse_slider, &QSlider::valueChanged, this, &MeshControlsWidget::on_material_diffuse_changed); - connect(_material_specular_slider, &QSlider::valueChanged, this, &MeshControlsWidget::on_material_specular_changed); - connect(_material_shininess_slider, &QSlider::valueChanged, this, &MeshControlsWidget::on_material_shininess_changed); + connect(_material_ambient_slider, + &QSlider::valueChanged, + this, + &MeshControlsWidget::on_material_ambient_changed); + connect(_material_diffuse_slider, + &QSlider::valueChanged, + this, + &MeshControlsWidget::on_material_diffuse_changed); + connect(_material_specular_slider, + &QSlider::valueChanged, + this, + &MeshControlsWidget::on_material_specular_changed); + connect(_material_shininess_slider, + &QSlider::valueChanged, + this, + &MeshControlsWidget::on_material_shininess_changed); } // ═════════════════════════════════════════════════════════════════════ @@ -532,7 +725,7 @@ void MeshControlsWidget::build_bvh_group(QWidget *parent, QBoxLayout *layout) { auto *kdop_row = new QHBoxLayout; kdop_row->addWidget(new QLabel("Bounding Volume:", group)); _bvh_kdop_combo = new QComboBox(group); - _bvh_kdop_combo->addItems({ "AABB (K=3)", "9-DOP", "13-DOP" }); + _bvh_kdop_combo->addItems({"AABB (K=3)", "9-DOP", "13-DOP"}); kdop_row->addWidget(_bvh_kdop_combo); grp_layout->addLayout(kdop_row); @@ -540,10 +733,10 @@ void MeshControlsWidget::build_bvh_group(QWidget *parent, QBoxLayout *layout) { auto *strat_row = new QHBoxLayout; strat_row->addWidget(new QLabel("Strategy:", group)); _bvh_strategy_combo = new QComboBox(group); - _bvh_strategy_combo->addItems({ "SAH (Best Quality)", - "Object Median (Balanced)", - "Spatial Median", - "LBVH (Fastest)" }); + _bvh_strategy_combo->addItems({"SAH (Best Quality)", + "Object Median (Balanced)", + "Spatial Median", + "LBVH (Fastest)"}); strat_row->addWidget(_bvh_strategy_combo); grp_layout->addLayout(strat_row); @@ -580,12 +773,30 @@ void MeshControlsWidget::build_bvh_group(QWidget *parent, QBoxLayout *layout) { layout->addWidget(group); - connect(_bvh_enabled_check, &QCheckBox::toggled, this, &MeshControlsWidget::on_bvh_enabled_changed); - connect(_bvh_kdop_combo, QOverload::of(&QComboBox::currentIndexChanged), this, &MeshControlsWidget::on_bvh_kdop_changed); - connect(_bvh_strategy_combo, QOverload::of(&QComboBox::currentIndexChanged), this, &MeshControlsWidget::on_bvh_strategy_changed); - connect(_bvh_leaf_size_slider, &QSlider::valueChanged, this, &MeshControlsWidget::on_bvh_leaf_size_changed); - connect(_bvh_depth_slider, &QSlider::valueChanged, this, &MeshControlsWidget::on_bvh_depth_changed); - connect(_bvh_color_button, &QPushButton::clicked, this, &MeshControlsWidget::on_bvh_color_clicked); + connect(_bvh_enabled_check, + &QCheckBox::toggled, + this, + &MeshControlsWidget::on_bvh_enabled_changed); + connect(_bvh_kdop_combo, + QOverload::of(&QComboBox::currentIndexChanged), + this, + &MeshControlsWidget::on_bvh_kdop_changed); + connect(_bvh_strategy_combo, + QOverload::of(&QComboBox::currentIndexChanged), + this, + &MeshControlsWidget::on_bvh_strategy_changed); + connect(_bvh_leaf_size_slider, + &QSlider::valueChanged, + this, + &MeshControlsWidget::on_bvh_leaf_size_changed); + connect(_bvh_depth_slider, + &QSlider::valueChanged, + this, + &MeshControlsWidget::on_bvh_depth_changed); + connect(_bvh_color_button, + &QPushButton::clicked, + this, + &MeshControlsWidget::on_bvh_color_clicked); } // ═════════════════════════════════════════════════════════════════════ @@ -603,7 +814,8 @@ QWidget *MeshControlsWidget::build_lighting_page() { return page; } -void MeshControlsWidget::build_scene_lighting_group(QWidget *parent, QBoxLayout *layout) { +void MeshControlsWidget::build_scene_lighting_group(QWidget *parent, + QBoxLayout *layout) { auto *group = new QGroupBox("Scene Lighting", parent); auto *grp_layout = new QVBoxLayout(group); @@ -623,7 +835,8 @@ void MeshControlsWidget::build_scene_lighting_group(QWidget *parent, QBoxLayout _scene_light_x_spin = new QDoubleSpinBox(group); _scene_light_y_spin = new QDoubleSpinBox(group); _scene_light_z_spin = new QDoubleSpinBox(group); - for (auto *s : { _scene_light_x_spin, _scene_light_y_spin, _scene_light_z_spin }) { + for (auto *s : + {_scene_light_x_spin, _scene_light_y_spin, _scene_light_z_spin}) { s->setRange(-1.0, 1.0); s->setDecimals(3); s->setSingleStep(0.01); @@ -646,11 +859,26 @@ void MeshControlsWidget::build_scene_lighting_group(QWidget *parent, QBoxLayout layout->addWidget(group); - connect(_scene_light_enabled_check, &QCheckBox::toggled, this, &MeshControlsWidget::on_scene_light_enabled_changed); - connect(_scene_light_x_spin, QOverload::of(&QDoubleSpinBox::valueChanged), this, &MeshControlsWidget::on_scene_light_dir_changed); - connect(_scene_light_y_spin, QOverload::of(&QDoubleSpinBox::valueChanged), this, &MeshControlsWidget::on_scene_light_dir_changed); - connect(_scene_light_z_spin, QOverload::of(&QDoubleSpinBox::valueChanged), this, &MeshControlsWidget::on_scene_light_dir_changed); - connect(_scene_light_color_button, &QPushButton::clicked, this, &MeshControlsWidget::on_scene_light_color_clicked); + connect(_scene_light_enabled_check, + &QCheckBox::toggled, + this, + &MeshControlsWidget::on_scene_light_enabled_changed); + connect(_scene_light_x_spin, + QOverload::of(&QDoubleSpinBox::valueChanged), + this, + &MeshControlsWidget::on_scene_light_dir_changed); + connect(_scene_light_y_spin, + QOverload::of(&QDoubleSpinBox::valueChanged), + this, + &MeshControlsWidget::on_scene_light_dir_changed); + connect(_scene_light_z_spin, + QOverload::of(&QDoubleSpinBox::valueChanged), + this, + &MeshControlsWidget::on_scene_light_dir_changed); + connect(_scene_light_color_button, + &QPushButton::clicked, + this, + &MeshControlsWidget::on_scene_light_color_clicked); } // ═════════════════════════════════════════════════════════════════════ @@ -687,19 +915,20 @@ void MeshControlsWidget::sync_from_state() { // Apply constraints before syncing UI — ensures widgets reflect // valid state even if the render state was mutated externally. bool mesh_has_normals = have_mesh && mesh_data->has_normals(); - if (have_mesh) { - mesh_data->render_state().constrain(mesh_has_normals); - } + if (have_mesh) { mesh_data->render_state().constrain(mesh_has_normals); } // Material tab is only visible when lighting is active. - bool is_lit = have_mesh && mesh_data->render_state().normal_source != vulkan::NormalSource::None; + bool is_lit = have_mesh + && mesh_data->render_state().normal_source + != vulkan::NormalSource::None; _tabs->setTabVisible(_tab_material, is_lit); if (_shading_details_container) { _shading_details_container->setVisible(is_lit); } // BVH tab - auto *bvh_data = have_selection ? obj->find_feature() : nullptr; + auto *bvh_data = + have_selection ? obj->find_feature() : nullptr; _tabs->setTabVisible(_tab_bvh, bvh_data != nullptr); // Mesh info labels visibility @@ -747,35 +976,45 @@ void MeshControlsWidget::sync_from_state() { sync_transform_from_object(); if (mesh_data) { - _vertex_count_label->setText(QStringLiteral("Vertices: %1").arg(static_cast(mesh_data->vertex_count()))); - _triangle_count_label->setText(QStringLiteral("Triangles: %1").arg(static_cast(mesh_data->triangle_count()))); - _edge_count_label->setText(QStringLiteral("Edges: %1").arg(static_cast(mesh_data->edge_count()))); + _vertex_count_label->setText( + QStringLiteral("Vertices: %1") + .arg(static_cast(mesh_data->vertex_count()))); + _triangle_count_label->setText( + QStringLiteral("Triangles: %1") + .arg(static_cast(mesh_data->triangle_count()))); + _edge_count_label->setText( + QStringLiteral("Edges: %1") + .arg(static_cast(mesh_data->edge_count()))); QString attrs; + if (mesh_data->has_positions()) attrs += "P "; if (mesh_data->has_normals()) attrs += "N "; - if (mesh_data->has_vertex_colors()) attrs += "C "; if (mesh_data->has_scalar_field()) attrs += "S "; - _attribute_label->setText(QStringLiteral("Attributes: %1").arg(attrs.isEmpty() ? "-" : attrs.trimmed())); + _attribute_label->setText( + QStringLiteral("Attributes: %1") + .arg(attrs.isEmpty() ? "-" : attrs.trimmed())); const auto &s = mesh_data->render_state(); // Render state _shading_combo->setCurrentIndex(static_cast(s.shading)); - _normal_source_combo->setCurrentIndex(static_cast(s.normal_source)); + _normal_source_combo->setCurrentIndex( + static_cast(s.normal_source)); _two_sided_check->setChecked(s.two_sided); _cull_mode_combo->setCurrentIndex(static_cast(s.cull_mode)); // Disable combo items that violate constraints. // "From Attribute" (index 0) requires actual normal data. - if (auto *model = qobject_cast(_normal_source_combo->model())) { + if (auto *model = qobject_cast( + _normal_source_combo->model())) { auto *item0 = model->item(0); - if (item0) { - item0->setEnabled(mesh_has_normals); - } + if (item0) { item0->setEnabled(mesh_has_normals); } } // "Gouraud" (1) and "Phong" (2) require FromAttribute normals. - if (auto *model = qobject_cast(_shading_combo->model())) { - bool can_smooth = (s.normal_source == vulkan::NormalSource::FromAttribute); + if (auto *model = + qobject_cast(_shading_combo->model())) { + bool can_smooth = + (s.normal_source == vulkan::NormalSource::FromAttribute); for (int i = 1; i <= 2; ++i) { auto *item = model->item(i); if (item) item->setEnabled(can_smooth); @@ -791,15 +1030,16 @@ void MeshControlsWidget::sync_from_state() { s.uniform_color[3]); int cmap_idx = find_colormap_index(s.colormap_name); - if (cmap_idx >= 0) { - _colormap_combo->setCurrentIndex(cmap_idx); - } + if (cmap_idx >= 0) { _colormap_combo->setCurrentIndex(cmap_idx); } _colormap_custom_edit->setText(QString::fromStdString(s.colormap_name)); _scalar_min_spin->setValue(static_cast(s.scalar_min)); _scalar_max_spin->setValue(static_cast(s.scalar_max)); sync_color_group_visibility(); + // Attribute bindings + sync_attribute_bindings(); + // Render layers const auto &layers = s.layers; @@ -819,8 +1059,10 @@ void MeshControlsWidget::sync_from_state() { layers.wireframe.color[3]); _wireframe_color_button->setVisible(layers.wireframe.enabled); _wireframe_details_container->setVisible(layers.wireframe.enabled); - _wireframe_width_slider->setValue(static_cast(layers.wireframe.width * 10.0f)); - _wireframe_width_label->setText(QStringLiteral("Width: %1").arg(layers.wireframe.width, 0, 'f', 1)); + _wireframe_width_slider->setValue( + static_cast(layers.wireframe.width * 10.0f)); + _wireframe_width_label->setText( + QStringLiteral("Width: %1").arg(layers.wireframe.width, 0, 'f', 1)); _points_enabled_check->setChecked(layers.points.enabled); set_button_color(_point_color_button, @@ -830,19 +1072,30 @@ void MeshControlsWidget::sync_from_state() { layers.points.color[3]); _point_color_button->setVisible(layers.points.enabled); _points_details_container->setVisible(layers.points.enabled); - _point_size_slider->setValue(static_cast(layers.points.size * 10.0f)); - _point_size_label->setText(QStringLiteral("Size: %1").arg(layers.points.size, 0, 'f', 1)); + _point_size_slider->setValue( + static_cast(layers.points.size * 10.0f)); + _point_size_label->setText( + QStringLiteral("Size: %1").arg(layers.points.size, 0, 'f', 1)); // Material const auto &mat = s.material; - _material_ambient_slider->setValue(static_cast(mat.ambient_strength * 100.0f)); - _material_diffuse_slider->setValue(static_cast(mat.diffuse_strength * 100.0f)); - _material_specular_slider->setValue(static_cast(mat.specular_strength * 100.0f)); + _material_ambient_slider->setValue( + static_cast(mat.ambient_strength * 100.0f)); + _material_diffuse_slider->setValue( + static_cast(mat.diffuse_strength * 100.0f)); + _material_specular_slider->setValue( + static_cast(mat.specular_strength * 100.0f)); _material_shininess_slider->setValue(static_cast(mat.shininess)); - _material_ambient_label->setText(QStringLiteral("Ambient: %1").arg(mat.ambient_strength, 0, 'f', 2)); - _material_diffuse_label->setText(QStringLiteral("Diffuse: %1").arg(mat.diffuse_strength, 0, 'f', 2)); - _material_specular_label->setText(QStringLiteral("Specular: %1").arg(mat.specular_strength, 0, 'f', 2)); - _material_shininess_label->setText(QStringLiteral("Shininess: %1").arg(static_cast(mat.shininess))); + _material_ambient_label->setText( + QStringLiteral("Ambient: %1").arg(mat.ambient_strength, 0, 'f', 2)); + _material_diffuse_label->setText( + QStringLiteral("Diffuse: %1").arg(mat.diffuse_strength, 0, 'f', 2)); + _material_specular_label->setText( + QStringLiteral("Specular: %1") + .arg(mat.specular_strength, 0, 'f', 2)); + _material_shininess_label->setText( + QStringLiteral("Shininess: %1") + .arg(static_cast(mat.shininess))); } // Scene lighting (always synced, not per-mesh) @@ -870,24 +1123,30 @@ void MeshControlsWidget::sync_from_state() { _bvh_enabled_check->setChecked(bvh_data->enabled); // KDOP combo: 0=K3, 1=K9, 2=K13 - int kdop_idx = (bvh_data->kdop_k == 3) ? 0 : (bvh_data->kdop_k == 9) ? 1 - : 2; + int kdop_idx = (bvh_data->kdop_k == 3) ? 0 + : (bvh_data->kdop_k == 9) ? 1 + : 2; _bvh_kdop_combo->setCurrentIndex(kdop_idx); - _bvh_strategy_combo->setCurrentIndex(static_cast(bvh_data->strategy)); + _bvh_strategy_combo->setCurrentIndex( + static_cast(bvh_data->strategy)); - _bvh_leaf_size_slider->setValue(static_cast(bvh_data->max_leaf_size)); - _bvh_leaf_size_label->setText(QStringLiteral("Max Leaf: %1").arg(bvh_data->max_leaf_size)); + _bvh_leaf_size_slider->setValue( + static_cast(bvh_data->max_leaf_size)); + _bvh_leaf_size_label->setText( + QStringLiteral("Max Leaf: %1").arg(bvh_data->max_leaf_size)); if (bvh_data->is_built()) { - _bvh_height_label->setText(QStringLiteral("BVH height: %1").arg(bvh_data->bvh_height())); + _bvh_height_label->setText( + QStringLiteral("BVH height: %1").arg(bvh_data->bvh_height())); _bvh_depth_slider->setRange(0, bvh_data->bvh_height()); } else { _bvh_height_label->setText("BVH height: -"); _bvh_depth_slider->setRange(0, 20); } _bvh_depth_slider->setValue(bvh_data->display_depth); - _bvh_depth_label->setText(QStringLiteral("Depth: %1").arg(bvh_data->display_depth)); + _bvh_depth_label->setText( + QStringLiteral("Depth: %1").arg(bvh_data->display_depth)); set_button_color(_bvh_color_button, bvh_data->color[0], @@ -902,7 +1161,111 @@ void MeshControlsWidget::sync_color_group_visibility() { if (!mesh_data) return; auto src = mesh_data->render_state().color_source; _uniform_color_container->setVisible(src == vulkan::ColorSource::Uniform); - _scalar_field_container->setVisible(src == vulkan::ColorSource::ScalarField); + _scalar_field_container->setVisible(src + == vulkan::ColorSource::ScalarField); +} + +void MeshControlsWidget::sync_attribute_bindings() { + auto *mesh_data = selected_mesh_data(); + bool have_mesh = (mesh_data != nullptr); + + _attr_bindings_group->setVisible(have_mesh); + if (!have_mesh) return; + + const auto &discovered = mesh_data->discovered_attributes(); + + // Block signals during repopulation + QSignalBlocker bp(_position_attr_combo); + QSignalBlocker bn(_normal_attr_combo); + QSignalBlocker bs(_scalar_attr_combo); + QSignalBlocker bc(_scalar_component_combo); + + // ── Helper: populate a combo with filtered discovered attributes ── + // Returns the combo index of the currently-bound attribute, or 0 (None). + auto populate_combo = [&](QComboBox *combo, + const sg::RoleBinding &binding, + auto filter_fn) -> int { + combo->clear(); + combo->addItem("(None)"); // index 0 = unbound + + int current_idx = 0; + for (int i = 0; i < static_cast(discovered.size()); ++i) { + if (!filter_fn(discovered[i])) continue; + + QString label = + QStringLiteral("%1 (dim%2, %3 comp, %4 elts)") + .arg(QString::fromStdString(discovered[i].name)) + .arg(static_cast(discovered[i].dimension)) + .arg(static_cast(discovered[i].component_count)) + .arg(static_cast(discovered[i].count)); + combo->addItem(label, QVariant(i)); // userData = discovered index + + if (binding.is_bound() + && &binding.source.attribute() + == &discovered[i].handle.attribute()) { + current_idx = combo->count() - 1; + } + } + return current_idx; + }; + + // Position: floating-point, 1+ components + int pos_idx = populate_combo(_position_attr_combo, + mesh_data->position_binding(), + [](const sg::DiscoveredAttribute &da) { + return da.is_floating_point + && da.component_count >= 1; + }); + _position_attr_combo->setCurrentIndex(pos_idx); + + // Normal: floating-point, 2+ components + int norm_idx = populate_combo(_normal_attr_combo, + mesh_data->normal_binding(), + [](const sg::DiscoveredAttribute &da) { + return da.is_floating_point + && da.component_count >= 2; + }); + _normal_attr_combo->setCurrentIndex(norm_idx); + + // Scalar: any attribute with 1+ components + int scl_idx = populate_combo(_scalar_attr_combo, + mesh_data->scalar_binding(), + [](const sg::DiscoveredAttribute &da) { + return da.component_count >= 1; + }); + _scalar_attr_combo->setCurrentIndex(scl_idx); + + // Scalar component selector + bool show_component = false; + if (mesh_data->has_scalar_field()) { + const auto &scl_bind = mesh_data->scalar_binding(); + uint8_t src_components = 0; + for (const auto &da : discovered) { + if (scl_bind.is_bound() + && &scl_bind.source.attribute() == &da.handle.attribute()) { + src_components = da.component_count; + break; + } + } + if (src_components > 1) { + show_component = true; + _scalar_component_combo->clear(); + _scalar_component_combo->addItem("Magnitude"); // index 0 -> comp -1 + static const char *comp_names[] = { + "X (0)", "Y (1)", "Z (2)", "W (3)"}; + int max_comp = std::min(static_cast(src_components), 4); + for (int c = 0; c < max_comp; ++c) { + _scalar_component_combo->addItem( + comp_names[c]); // index c+1 -> comp c + } + // Map current component: -1 -> 0, 0 -> 1, 1 -> 2, ... + int combo_idx = mesh_data->scalar_component() + 1; + int max_items = max_comp + 1; + if (combo_idx < 0 || combo_idx >= max_items) combo_idx = 0; + _scalar_component_combo->setCurrentIndex(combo_idx); + } + } + _scalar_component_container->setVisible(show_component); } void MeshControlsWidget::sync_transform_from_object() { @@ -998,7 +1361,8 @@ void MeshControlsWidget::on_uniform_color_clicked() { if (!md) return; auto &uc = md->render_state().uniform_color; QColor initial = QColor::fromRgbF(uc[0], uc[1], uc[2], uc[3]); - QColor chosen = QColorDialog::getColor(initial, this, "Uniform Color", QColorDialog::ShowAlphaChannel); + QColor chosen = QColorDialog::getColor( + initial, this, "Uniform Color", QColorDialog::ShowAlphaChannel); if (chosen.isValid()) { uc[0] = static_cast(chosen.redF()); uc[1] = static_cast(chosen.greenF()); @@ -1015,7 +1379,8 @@ void MeshControlsWidget::on_colormap_changed(int index) { md->render_state().colormap_name = k_colormap_names[index]; { QSignalBlocker block(_colormap_custom_edit); - _colormap_custom_edit->setText(QString::fromStdString(md->render_state().colormap_name)); + _colormap_custom_edit->setText( + QString::fromStdString(md->render_state().colormap_name)); } emit scene_changed(); } @@ -1023,7 +1388,8 @@ void MeshControlsWidget::on_colormap_changed(int index) { void MeshControlsWidget::on_colormap_custom_edited() { auto *md = selected_mesh_data(); if (!md) return; - md->render_state().colormap_name = _colormap_custom_edit->text().toStdString(); + md->render_state().colormap_name = + _colormap_custom_edit->text().toStdString(); // Try to sync the combo to match int idx = find_colormap_index(md->render_state().colormap_name); if (idx >= 0) { @@ -1061,6 +1427,84 @@ void MeshControlsWidget::on_scalar_range_reset() { emit scene_changed(); } +// ═════════════════════════════════════════════════════════════════════ +// Slots: Attribute bindings +// ═════════════════════════════════════════════════════════════════════ + +void MeshControlsWidget::on_position_attr_changed(int index) { + auto *md = selected_mesh_data(); + if (!md) return; + + if (index <= 0) { + md->clear_position(); + } else { + QVariant data = _position_attr_combo->itemData(index); + if (data.isValid()) { + int disc_idx = data.toInt(); + const auto &discovered = md->discovered_attributes(); + if (disc_idx >= 0 + && disc_idx < static_cast(discovered.size())) { + md->assign_position(discovered[disc_idx].handle); + } + } + } + sync_from_state(); + emit scene_changed(); +} + +void MeshControlsWidget::on_normal_attr_changed(int index) { + auto *md = selected_mesh_data(); + if (!md) return; + + if (index <= 0) { + md->clear_normal(); + } else { + QVariant data = _normal_attr_combo->itemData(index); + if (data.isValid()) { + int disc_idx = data.toInt(); + const auto &discovered = md->discovered_attributes(); + if (disc_idx >= 0 + && disc_idx < static_cast(discovered.size())) { + md->assign_normal(discovered[disc_idx].handle); + } + } + } + sync_from_state(); + emit scene_changed(); +} + +void MeshControlsWidget::on_scalar_attr_changed(int index) { + auto *md = selected_mesh_data(); + if (!md) return; + + if (index <= 0) { + md->clear_scalar(); + } else { + QVariant data = _scalar_attr_combo->itemData(index); + if (data.isValid()) { + int disc_idx = data.toInt(); + const auto &discovered = md->discovered_attributes(); + if (disc_idx >= 0 + && disc_idx < static_cast(discovered.size())) { + md->assign_scalar(discovered[disc_idx].handle, + md->scalar_component()); + } + } + } + sync_from_state(); + emit scene_changed(); +} + +void MeshControlsWidget::on_scalar_component_changed(int combo_idx) { + auto *md = selected_mesh_data(); + if (!md || !md->has_scalar_field()) return; + + // Map combo index: 0 -> -1 (Magnitude), 1 -> 0 (X), 2 -> 1 (Y), ... + int new_comp = combo_idx - 1; + md->assign_scalar(md->scalar_binding().source, new_comp); + emit scene_changed(); +} + // ═════════════════════════════════════════════════════════════════════ // Slots: Render layers // ═════════════════════════════════════════════════════════════════════ @@ -1078,7 +1522,8 @@ void MeshControlsWidget::on_solid_color_clicked() { if (!md) return; auto &c = md->render_state().layers.solid.color; QColor initial = QColor::fromRgbF(c[0], c[1], c[2], c[3]); - QColor chosen = QColorDialog::getColor(initial, this, "Solid Color", QColorDialog::ShowAlphaChannel); + QColor chosen = QColorDialog::getColor( + initial, this, "Solid Color", QColorDialog::ShowAlphaChannel); if (chosen.isValid()) { c[0] = static_cast(chosen.redF()); c[1] = static_cast(chosen.greenF()); @@ -1103,7 +1548,8 @@ void MeshControlsWidget::on_wireframe_color_clicked() { if (!md) return; auto &c = md->render_state().layers.wireframe.color; QColor initial = QColor::fromRgbF(c[0], c[1], c[2], c[3]); - QColor chosen = QColorDialog::getColor(initial, this, "Wireframe Color", QColorDialog::ShowAlphaChannel); + QColor chosen = QColorDialog::getColor( + initial, this, "Wireframe Color", QColorDialog::ShowAlphaChannel); if (chosen.isValid()) { c[0] = static_cast(chosen.redF()); c[1] = static_cast(chosen.greenF()); @@ -1119,7 +1565,8 @@ void MeshControlsWidget::on_wireframe_width_changed(int value) { if (!md) return; float width = static_cast(value) / 10.0f; md->render_state().layers.wireframe.width = width; - _wireframe_width_label->setText(QStringLiteral("Width: %1").arg(width, 0, 'f', 1)); + _wireframe_width_label->setText( + QStringLiteral("Width: %1").arg(width, 0, 'f', 1)); emit scene_changed(); } @@ -1137,7 +1584,8 @@ void MeshControlsWidget::on_point_color_clicked() { if (!md) return; auto &c = md->render_state().layers.points.color; QColor initial = QColor::fromRgbF(c[0], c[1], c[2], c[3]); - QColor chosen = QColorDialog::getColor(initial, this, "Point Color", QColorDialog::ShowAlphaChannel); + QColor chosen = QColorDialog::getColor( + initial, this, "Point Color", QColorDialog::ShowAlphaChannel); if (chosen.isValid()) { c[0] = static_cast(chosen.redF()); c[1] = static_cast(chosen.greenF()); @@ -1166,7 +1614,8 @@ void MeshControlsWidget::on_material_ambient_changed(int value) { if (!md) return; float v = static_cast(value) / 100.0f; md->render_state().material.ambient_strength = v; - _material_ambient_label->setText(QStringLiteral("Ambient: %1").arg(v, 0, 'f', 2)); + _material_ambient_label->setText( + QStringLiteral("Ambient: %1").arg(v, 0, 'f', 2)); emit scene_changed(); } @@ -1175,7 +1624,8 @@ void MeshControlsWidget::on_material_specular_changed(int value) { if (!md) return; float v = static_cast(value) / 100.0f; md->render_state().material.specular_strength = v; - _material_specular_label->setText(QStringLiteral("Specular: %1").arg(v, 0, 'f', 2)); + _material_specular_label->setText( + QStringLiteral("Specular: %1").arg(v, 0, 'f', 2)); emit scene_changed(); } @@ -1183,7 +1633,8 @@ void MeshControlsWidget::on_material_shininess_changed(int value) { auto *md = selected_mesh_data(); if (!md) return; md->render_state().material.shininess = static_cast(value); - _material_shininess_label->setText(QStringLiteral("Shininess: %1").arg(value)); + _material_shininess_label->setText( + QStringLiteral("Shininess: %1").arg(value)); emit scene_changed(); } @@ -1192,7 +1643,8 @@ void MeshControlsWidget::on_material_diffuse_changed(int value) { if (!md) return; float v = static_cast(value) / 100.0f; md->render_state().material.diffuse_strength = v; - _material_diffuse_label->setText(QStringLiteral("Diffuse: %1").arg(v, 0, 'f', 2)); + _material_diffuse_label->setText( + QStringLiteral("Diffuse: %1").arg(v, 0, 'f', 2)); emit scene_changed(); } @@ -1218,10 +1670,9 @@ void MeshControlsWidget::on_scene_light_dir_changed() { void MeshControlsWidget::on_scene_light_color_clicked() { if (!_scene) return; auto &light = _scene->headlight(); - QColor initial = QColor::fromRgbF( - static_cast(light.color(0)), - static_cast(light.color(1)), - static_cast(light.color(2))); + QColor initial = QColor::fromRgbF(static_cast(light.color(0)), + static_cast(light.color(1)), + static_cast(light.color(2))); QColor chosen = QColorDialog::getColor(initial, this, "Light Color"); if (chosen.isValid()) { light.color(0) = static_cast(chosen.redF()); @@ -1251,7 +1702,7 @@ void MeshControlsWidget::on_bvh_enabled_changed(bool checked) { void MeshControlsWidget::on_bvh_kdop_changed(int index) { auto *bvh = selected_bvh_data(); if (!bvh) return; - static constexpr int k_values[] = { 3, 9, 13 }; + static constexpr int k_values[] = {3, 9, 13}; if (index >= 0 && index < 3) { bvh->kdop_k = k_values[index]; bvh->mark_dirty(); @@ -1290,14 +1741,20 @@ void MeshControlsWidget::on_bvh_depth_changed(int value) { void MeshControlsWidget::on_bvh_color_clicked() { auto *bvh = selected_bvh_data(); if (!bvh) return; - QColor initial = QColor::fromRgbF(bvh->color[0], bvh->color[1], bvh->color[2], bvh->color[3]); - QColor chosen = QColorDialog::getColor(initial, this, "BVH Overlay Color", QColorDialog::ShowAlphaChannel); + QColor initial = QColor::fromRgbF( + bvh->color[0], bvh->color[1], bvh->color[2], bvh->color[3]); + QColor chosen = QColorDialog::getColor( + initial, this, "BVH Overlay Color", QColorDialog::ShowAlphaChannel); if (chosen.isValid()) { bvh->color[0] = static_cast(chosen.redF()); bvh->color[1] = static_cast(chosen.greenF()); bvh->color[2] = static_cast(chosen.blueF()); bvh->color[3] = static_cast(chosen.alphaF()); - set_button_color(_bvh_color_button, bvh->color[0], bvh->color[1], bvh->color[2], bvh->color[3]); + set_button_color(_bvh_color_button, + bvh->color[0], + bvh->color[1], + bvh->color[2], + bvh->color[3]); bvh->mark_overlay_dirty(); emit scene_changed(); } @@ -1354,4 +1811,4 @@ void MeshControlsWidget::on_reset_transform() { emit scene_changed(); } -}// namespace balsa::visualization::qt +} // namespace balsa::visualization::qt diff --git a/visualization/src/scene_graph/BVHData.cpp b/visualization/src/scene_graph/BVHData.cpp index 2ff4483..5af46b1 100644 --- a/visualization/src/scene_graph/BVHData.cpp +++ b/visualization/src/scene_graph/BVHData.cpp @@ -18,13 +18,12 @@ namespace balsa::scene_graph { // ── Collect BVH node bounds at a given tree depth ─────────────────── -template +template static auto collect_bounds_at_depth( - const quiver::spatial::BVH &bvh, - int target_depth) - -> std::vector> { - - using kdop_type = quiver::spatial::KDOP; + const quiver::spatial::BVH &bvh, + int target_depth) + -> std::vector> { + using kdop_type = quiver::spatial::KDOP; std::vector result; if (!bvh.is_built() || bvh.node_count() == 0) return result; @@ -32,7 +31,7 @@ static auto collect_bounds_at_depth( auto nodes = bvh.nodes(); std::stack> stack; - stack.push({ 0, 0 }); + stack.push({0, 0}); while (!stack.empty()) { auto [idx, depth] = stack.top(); @@ -43,8 +42,8 @@ static auto collect_bounds_at_depth( if (depth == target_depth || node.is_leaf()) { result.push_back(node.bounds); } else { - stack.push({ node.right_child(), depth + 1 }); - stack.push({ idx + 1, depth + 1 });// left child (implicit) + stack.push({node.right_child(), depth + 1}); + stack.push({idx + 1, depth + 1}); // left child (implicit) } } @@ -52,38 +51,53 @@ static auto collect_bounds_at_depth( } // ── Set wireframe geometry from KDOP bounds ───────────────────────── +// +// Creates a quiver::Mesh<1> (edge mesh) from the KDOP wireframe +// geometry, attaches float vertex positions, and sets it on the +// overlay MeshData via set_mesh(). -template -static void set_kdop_wireframe( - MeshData &mesh_data, - const std::vector> &bounds, - float uniform_color[4]) { +using Vec3fArr = std::array; - std::vector all_positions; - std::vector all_edges; +template +static void set_kdop_wireframe( + MeshData &mesh_data, + const std::vector> &bounds, + float uniform_color[4]) { + std::vector all_positions; + std::vector> all_edges; for (const auto &kdop : bounds) { auto geom = quiver::spatial::kdop_geometry(kdop); - uint32_t base = static_cast(all_positions.size()); + auto base = static_cast(all_positions.size()); for (const auto &v : geom.vertices) { - Vec3f pos; - pos(0) = static_cast(v[0]); - pos(1) = static_cast(v[1]); - pos(2) = static_cast(v[2]); - all_positions.push_back(pos); + all_positions.push_back({static_cast(v[0]), + static_cast(v[1]), + static_cast(v[2])}); } for (const auto &[a, b] : geom.edges) { - all_edges.push_back(base + static_cast(a)); - all_edges.push_back(base + static_cast(b)); + all_edges.push_back({base + static_cast(a), + base + static_cast(b)}); } } - mesh_data.set_positions(all_positions); - mesh_data.set_edge_indices(all_edges); + // Build edge mesh from vertex indices. + auto mesh = quiver::Mesh<1>::from_vertex_indices( + std::span>(all_edges)); + + // Create float vertex_positions attribute. + auto pos_handle = mesh.create_attribute("vertex_positions", 0); + for (std::size_t i = 0; i < all_positions.size(); ++i) { + pos_handle[i] = all_positions[i]; + } + + // Set the mesh on the overlay MeshData (auto-discovers attributes, + // auto-assigns vertex_positions → position role). + mesh_data.set_mesh(std::make_shared>(std::move(mesh))); + // Configure render state for wireframe-only display. auto &rs = mesh_data.render_state(); rs.layers.solid.enabled = false; rs.layers.wireframe.enabled = true; @@ -101,29 +115,25 @@ static void set_kdop_wireframe( rs.shading = visualization::vulkan::ShadingModel::Flat; } -// ── Build quiver::Mesh<2> from MeshData positions + triangles ─────── +// ── Build quiver::Mesh<2> from float positions + triangle indices ──── // -// Converts MeshData's flat float positions (Vec3f) and uint32_t +// Converts flat float positions (array) and uint32_t // triangle indices into the quiver::Mesh<2> representation needed by // quiver::spatial::make_bvh. using Vec3d = std::array; -static auto make_quiver_mesh( - std::span positions, - std::span tri_indices) - -> std::pair, - quiver::attributes::AttributeHandle> { - +static auto make_quiver_mesh(std::span positions, + std::span tri_indices) + -> std::pair, + quiver::attributes::AttributeHandle> { // Convert uint32_t triples → int64_t triples for from_vertex_indices. size_t n_tris = tri_indices.size() / 3; std::vector> tris(n_tris); for (size_t t = 0; t < n_tris; ++t) { - tris[t] = { - static_cast(tri_indices[t * 3 + 0]), - static_cast(tri_indices[t * 3 + 1]), - static_cast(tri_indices[t * 3 + 2]) - }; + tris[t] = {static_cast(tri_indices[t * 3 + 0]), + static_cast(tri_indices[t * 3 + 1]), + static_cast(tri_indices[t * 3 + 2])}; } auto mesh = quiver::Mesh<2>::from_vertex_indices(tris); @@ -131,49 +141,64 @@ static auto make_quiver_mesh( // Create a vertex position attribute (double, as required by KDOP). auto pos_handle = mesh.create_attribute("positions", 0); for (size_t i = 0; i < positions.size(); ++i) { - pos_handle[i] = { - static_cast(positions[i](0)), - static_cast(positions[i](1)), - static_cast(positions[i](2)) - }; + pos_handle[i] = {static_cast(positions[i][0]), + static_cast(positions[i][1]), + static_cast(positions[i][2])}; } // Return const handle for make_bvh. quiver::attributes::AttributeHandle cpos(pos_handle); - return { std::move(mesh), cpos }; + return {std::move(mesh), cpos}; } // ── BVHData::rebuild_bvh ──────────────────────────────────────────── void BVHData::rebuild_bvh(MeshData &mesh_data) { - auto positions = mesh_data.positions(); + const auto &pos_binding = mesh_data.position_binding(); auto tri_indices = mesh_data.triangle_indices(); - if (positions.empty() || tri_indices.empty()) { + if (!pos_binding.is_bound() || tri_indices.empty()) { _bvh_height = -1; return; } - auto [mesh, pos] = make_quiver_mesh(positions, tri_indices); + // Extract float positions from the binding as Vec3f-like data. + // raw_data() returns contiguous floats; component_count tells stride. + const auto *raw = static_cast(pos_binding.raw_data()); + std::size_t n_verts = pos_binding.size(); + uint8_t comps = pos_binding.component_count; + + // Build a vector of Vec3f from raw float data (pad missing components). + std::vector positions(n_verts); + for (std::size_t i = 0; i < n_verts; ++i) { + const float *src = raw + i * comps; + positions[i] = {comps >= 1 ? src[0] : 0.0f, + comps >= 2 ? src[1] : 0.0f, + comps >= 3 ? src[2] : 0.0f}; + } + + auto [mesh, pos] = + make_quiver_mesh(std::span(positions), tri_indices); quiver::spatial::BVHConfig config; config.strategy = strategy; config.max_leaf_size = max_leaf_size; if (kdop_k == 3) { - _bvh_3 = quiver::spatial::make_bvh<2, 3, 3>(mesh, pos, config); + _bvh_3 = quiver::spatial::make_bvh(mesh, pos, config); _bvh_height = _bvh_3.height(); spdlog::info("Built AABB BVH: {} nodes, height {}", _bvh_3.node_count(), _bvh_height); } else if (kdop_k == 9) { - _bvh_9 = quiver::spatial::make_bvh<2, 3, 9>(mesh, pos, config); + _bvh_9 = quiver::spatial::make_bvh(mesh, pos, config); _bvh_height = _bvh_9.height(); spdlog::info("Built 9-DOP BVH: {} nodes, height {}", _bvh_9.node_count(), _bvh_height); } else { - _bvh_13 = quiver::spatial::make_bvh<2, 3, 13>(mesh, pos, config); + _bvh_13 = + quiver::spatial::make_bvh(mesh, pos, config); _bvh_height = _bvh_13.height(); spdlog::info("Built 13-DOP BVH: {} nodes, height {}", _bvh_13.node_count(), @@ -192,9 +217,9 @@ void BVHData::update_overlay() { // Create a child Object on the owning mesh Object. auto &parent = object(); - auto &child = parent.add_child( - "BVH K=" + std::to_string(kdop_k) + " d=" + std::to_string(display_depth)); - child.selectable = false;// not user-selectable in the outliner + auto &child = parent.add_child("BVH K=" + std::to_string(kdop_k) + + " d=" + std::to_string(display_depth)); + child.selectable = false; // not user-selectable in the outliner _overlay_obj = &child; auto &md = child.emplace_feature(); @@ -236,4 +261,4 @@ bool BVHData::apply_pending_update(MeshData &mesh_data) { return true; } -}// namespace balsa::scene_graph +} // namespace balsa::scene_graph diff --git a/visualization/src/scene_graph/ImageData.cpp b/visualization/src/scene_graph/ImageData.cpp new file mode 100644 index 0000000..d5f8264 --- /dev/null +++ b/visualization/src/scene_graph/ImageData.cpp @@ -0,0 +1,103 @@ +#include "balsa/scene_graph/ImageData.hpp" + +#include +#include +#include + +namespace balsa::scene_graph { + +// ── Helpers ───────────────────────────────────────────────────────── + +auto ImageData::bytes_per_pixel() const -> size_t { + switch (_format) { + case Format::RGBA8: + return 4; + case Format::RGBAF32: + return 16; + } + return 4; +} + +// ── set_pixels (full image replacement) ───────────────────────────── + +auto ImageData::set_pixels(uint32_t width, + uint32_t height, + Format format, + std::span data) -> void { + size_t bpp = (format == Format::RGBAF32) ? 16 : 4; + size_t expected = static_cast(width) * height * bpp; + if (data.size() < expected) { + throw std::runtime_error("ImageData::set_pixels: insufficient data"); + } + + _width = width; + _height = height; + _format = format; + _pixels.assign(data.begin(), data.begin() + expected); + ++_version; + _dirty = DirtyRegion(0, 0, width, height); + _full_dirty = true; +} + +auto ImageData::set_pixels_rgba8(uint32_t width, + uint32_t height, + std::span rgba) -> void { + auto bytes = std::as_bytes(rgba); + set_pixels(width, height, Format::RGBA8, bytes); +} + +auto ImageData::set_pixels_rgbaf32(uint32_t width, + uint32_t height, + std::span rgba) -> void { + auto bytes = std::as_bytes(rgba); + set_pixels(width, height, Format::RGBAF32, bytes); +} + +// ── update_region (partial update) ────────────────────────────────── + +auto ImageData::update_region(uint32_t x, + uint32_t y, + uint32_t w, + uint32_t h, + std::span data) -> void { + if (_pixels.empty()) { + throw std::runtime_error("ImageData::update_region: no image set"); + } + + // Clamp region to image bounds. + if (x + w > _width || y + h > _height) { + throw std::runtime_error( + "ImageData::update_region: region exceeds image bounds"); + } + + size_t bpp = bytes_per_pixel(); + size_t expected = static_cast(w) * h * bpp; + if (data.size() < expected) { + throw std::runtime_error("ImageData::update_region: insufficient data"); + } + + // Copy rows into the pixel buffer. + size_t row_bytes = static_cast(w) * bpp; + for (uint32_t row = 0; row < h; ++row) { + size_t src_offset = static_cast(row) * row_bytes; + size_t dst_offset = (static_cast(y + row) * _width + x) * bpp; + std::memcpy( + _pixels.data() + dst_offset, data.data() + src_offset, row_bytes); + } + + ++_version; + _full_dirty = false; + + // Merge with existing dirty region (union of rectangles). + if (_dirty) { + uint32_t x0 = std::min(_dirty->min(0), x); + uint32_t y0 = std::min(_dirty->min(1), y); + uint32_t x1 = std::max(_dirty->min(0) + _dirty->width(), x + w); + uint32_t y1 = std::max(_dirty->min(1) + _dirty->height(), y + h); + _dirty = DirtyRegion(x0, y0, x1, y1); + } else { + _dirty = DirtyRegion(x, y, x + w, y + h); + } +} + +} // namespace balsa::scene_graph diff --git a/visualization/src/scene_graph/MeshData.cpp b/visualization/src/scene_graph/MeshData.cpp index 98572b1..1070358 100644 --- a/visualization/src/scene_graph/MeshData.cpp +++ b/visualization/src/scene_graph/MeshData.cpp @@ -1,139 +1,674 @@ #include "balsa/scene_graph/MeshData.hpp" +#include + +#include +#include #include +#include + +#include +#include +#include namespace balsa::scene_graph { -MeshData::MeshData() - : _h_positions(_attrs.create("positions")), _h_normals(_attrs.create("normals")), _h_triangle_indices(_attrs.create("triangle_indices")), _h_edge_indices(_attrs.create("edge_indices")), _h_vertex_colors(_attrs.create("vertex_colors")), _h_scalar_field(_attrs.create("scalar_field")) {} +namespace qattr = quiver::attributes; -// ── Geometry mutators ─────────────────────────────────────────────── +// ── RoleBinding implementation ────────────────────────────────────── -void MeshData::set_positions(std::span positions) { - _h_positions.attribute().mutable_data().assign( - positions.begin(), positions.end()); - ++_version; +std::size_t RoleBinding::size() const { + if (!gpu_ready.valid()) return 0; + return gpu_ready.attribute().size(); } -void MeshData::set_normals(std::span normals) { - _h_normals.attribute().mutable_data().assign( - normals.begin(), normals.end()); - ++_version; +const void *RoleBinding::raw_data() const { + if (!gpu_ready.valid()) return nullptr; + + // gpu_ready always points at a StoredAttribute> or + // StoredAttribute. We must dynamic_cast to the concrete type + // to access the data vector. + auto &attr = gpu_ready.attribute(); + + // Try each float-typed StoredAttribute we could have created. + if (auto *p = dynamic_cast *>(&attr)) { + return p->data().data(); + } + if (auto *p = + dynamic_cast> *>( + &attr)) { + return p->data().data(); + } + if (auto *p = + dynamic_cast> *>( + &attr)) { + return p->data().data(); + } + if (auto *p = + dynamic_cast> *>( + &attr)) { + return p->data().data(); + } + return nullptr; +} + +void RoleBinding::clear() { + source = qattr::ConstTypeErasedAttributeHandle(); + cached.reset(); + gpu_ready = qattr::ConstTypeErasedAttributeHandle(); + component_count = 0; } -void MeshData::set_triangle_indices(std::span indices) { - _h_triangle_indices.attribute().mutable_data().assign( - indices.begin(), indices.end()); +// ── Type introspection helpers ────────────────────────────────────── +// +// Since StoredAttributeBase has NO virtual type introspection (no +// value_type_id, no element_size, no raw_data), we probe known concrete +// types via ConstTypeErasedAttributeHandle::to_handle(), which +// internally uses dynamic_cast. - // Build quiver Mesh<2> topology from triangle indices. - std::size_t n_tris = indices.size() / 3; - if (n_tris > 0) { - std::vector> qtris(n_tris); - for (std::size_t j = 0; j < n_tris; ++j) { - qtris[j] = { - static_cast(indices[j * 3 + 0]), - static_cast(indices[j * 3 + 1]), - static_cast(indices[j * 3 + 2]) - }; +namespace { + + // Result of probing a type-erased handle for its concrete type. + struct TypeProbeResult { + std::type_index type_id = typeid(void); + std::size_t element_size = 0; + uint8_t component_count = 0; + bool is_floating_point = false; + }; + + // Probe a type-erased handle against all known attribute types. + // Returns the first match. Order doesn't matter for correctness. + TypeProbeResult probe_type(qattr::ConstTypeErasedAttributeHandle handle) { + if (!handle.valid()) return {}; + + // Try float types. + if (handle.to_handle()) { + return {typeid(float), sizeof(float), 1, true}; + } + if (handle.to_handle>()) { + return {typeid(std::array), + sizeof(std::array), + 2, + true}; } - _topology = quiver::Mesh<2>::from_vertex_indices(qtris); + if (handle.to_handle>()) { + return {typeid(std::array), + sizeof(std::array), + 3, + true}; + } + if (handle.to_handle>()) { + return {typeid(std::array), + sizeof(std::array), + 4, + true}; + } + // Try double types. + if (handle.to_handle()) { + return {typeid(double), sizeof(double), 1, true}; + } + if (handle.to_handle>()) { + return {typeid(std::array), + sizeof(std::array), + 2, + true}; + } + if (handle.to_handle>()) { + return {typeid(std::array), + sizeof(std::array), + 3, + true}; + } + if (handle.to_handle>()) { + return {typeid(std::array), + sizeof(std::array), + 4, + true}; + } + // Try integer types. + if (handle.to_handle()) { + return {typeid(int), sizeof(int), 1, false}; + } + if (handle.to_handle()) { + return {typeid(int64_t), sizeof(int64_t), 1, false}; + } + if (handle.to_handle>()) { + return {typeid(std::array), + sizeof(std::array), + 2, + false}; + } + if (handle.to_handle>()) { + return {typeid(std::array), + sizeof(std::array), + 3, + false}; + } + if (handle.to_handle>()) { + return {typeid(std::array), + sizeof(std::array), + 2, + false}; + } + if (handle.to_handle>()) { + return {typeid(std::array), + sizeof(std::array), + 3, + false}; + } + return {}; + } + + bool is_double_type(std::type_index tid) { + return tid == typeid(double) || tid == typeid(std::array) + || tid == typeid(std::array) + || tid == typeid(std::array); + } - // Auto-derive edges unless the user explicitly set them. - if (!_explicit_edges) { - derive_edges_from_topology(); + bool is_float_type(std::type_index tid) { + return tid == typeid(float) || tid == typeid(std::array) + || tid == typeid(std::array) + || tid == typeid(std::array); + } + + // ── CachedAttribute builders for double→float conversion ──────────── + // + // Each function creates a StoredAttribute filled manually + // from a typed source handle. The result is owned by MeshData via + // unique_ptr. + + struct CacheResult { + std::unique_ptr owned; + qattr::ConstTypeErasedAttributeHandle handle; + uint8_t component_count = 0; + }; + + // Helper: build a StoredAttribute by applying a functor + // to each element of a typed source handle. + template + CacheResult make_cached(qattr::AttributeHandle src_handle, + Functor fn, + uint8_t components) { + auto result = std::make_unique>(); + const std::size_t n = src_handle.size(); + result->resize(n); + for (std::size_t i = 0; i < n; ++i) { + (*result)[i] = fn(src_handle[i]); } - } else { - _topology.reset(); - if (!_explicit_edges) { - _h_edge_indices.attribute().mutable_data().clear(); + + CacheResult cr; + cr.handle = qattr::ConstTypeErasedAttributeHandle(*result); + cr.owned = std::move(result); + cr.component_count = components; + return cr; + } + + // Build a float conversion cache for a vector-valued attribute. + // Dispatches on the probed type_id. + CacheResult + build_float_cache_vector(qattr::ConstTypeErasedAttributeHandle source, + std::type_index tid) { + if (tid == typeid(std::array)) { + auto h = source.to_handle>(); + if (!h) return {}; + return make_cached>( + *h, + [](const std::array &v) -> std::array { + return {float(v[0]), float(v[1]), float(v[2])}; + }, + 3); + } + if (tid == typeid(std::array)) { + auto h = source.to_handle>(); + if (!h) return {}; + return make_cached>( + *h, + [](const std::array &v) -> std::array { + return {float(v[0]), float(v[1])}; + }, + 2); + } + if (tid == typeid(std::array)) { + auto h = source.to_handle>(); + if (!h) return {}; + return make_cached>( + *h, + [](const std::array &v) -> std::array { + return {float(v[0]), float(v[1]), float(v[2]), float(v[3])}; + }, + 4); + } + if (tid == typeid(double)) { + auto h = source.to_handle(); + if (!h) return {}; + return make_cached( + *h, [](const double &v) -> float { return float(v); }, 1); + } + return {}; + } + + // Build a float conversion cache for a scalar attribute, potentially + // extracting a single component from a multi-component source. + CacheResult + build_float_cache_scalar(qattr::ConstTypeErasedAttributeHandle source, + std::type_index tid, + int component) { + // Already float scalar — no conversion needed if component == -1. + if (tid == typeid(float) && component < 0) { + return {}; // caller should use source directly + } + + // Double scalar → float. + if (tid == typeid(double)) { + auto h = source.to_handle(); + if (!h) return {}; + return make_cached( + *h, [](const double &v) -> float { return float(v); }, 1); + } + + // Float array — extract component or magnitude. + auto extract_float_array = + [&]( + qattr::AttributeHandle> h) + -> CacheResult { + if (component >= 0 && component < static_cast(N)) { + int c = component; + return make_cached( + h, + [c](const std::array &v) -> float { + return v[static_cast(c)]; + }, + 1); + } + // Magnitude. + return make_cached( + h, + [](const std::array &v) -> float { + float sum = 0.0f; + for (std::size_t i = 0; i < N; ++i) sum += v[i] * v[i]; + return std::sqrt(sum); + }, + 1); + }; + + // Double array — extract component or magnitude, convert to float. + auto extract_double_array = + [&]( + qattr::AttributeHandle> h) + -> CacheResult { + if (component >= 0 && component < static_cast(N)) { + int c = component; + return make_cached( + h, + [c](const std::array &v) -> float { + return float(v[static_cast(c)]); + }, + 1); + } + // Magnitude. + return make_cached( + h, + [](const std::array &v) -> float { + double sum = 0.0; + for (std::size_t i = 0; i < N; ++i) sum += v[i] * v[i]; + return float(std::sqrt(sum)); + }, + 1); + }; + + // Integer types — convert to float. + if (tid == typeid(int)) { + auto h = source.to_handle(); + if (!h) return {}; + return make_cached( + *h, [](const int &v) -> float { return float(v); }, 1); + } + if (tid == typeid(int64_t)) { + auto h = source.to_handle(); + if (!h) return {}; + return make_cached( + *h, [](const int64_t &v) -> float { return float(v); }, 1); + } + + // Dispatch array types. + if (tid == typeid(std::array)) { + auto h = source.to_handle>(); + if (h) return extract_float_array.template operator()<2>(*h); + } + if (tid == typeid(std::array)) { + auto h = source.to_handle>(); + if (h) return extract_float_array.template operator()<3>(*h); + } + if (tid == typeid(std::array)) { + auto h = source.to_handle>(); + if (h) return extract_float_array.template operator()<4>(*h); + } + if (tid == typeid(std::array)) { + auto h = source.to_handle>(); + if (h) return extract_double_array.template operator()<2>(*h); + } + if (tid == typeid(std::array)) { + auto h = source.to_handle>(); + if (h) return extract_double_array.template operator()<3>(*h); + } + if (tid == typeid(std::array)) { + auto h = source.to_handle>(); + if (h) return extract_double_array.template operator()<4>(*h); + } + + return {}; + } + + // Extract triangle indices from a Mesh<2>. + void extract_tri_indices(const quiver::Mesh<2> &mesh, + std::vector &out) { + const auto &skel0 = mesh.skeleton<0>(); + quiver::attributes::IncidentFaceIndices<2, 0, 2> tri_verts(skel0); + + std::size_t n_tris = tri_verts.size(); + out.resize(n_tris * 3); + for (std::size_t t = 0; t < n_tris; ++t) { + auto [v0, v1, v2] = tri_verts.get_indices(t); + out[t * 3 + 0] = static_cast(v0); + out[t * 3 + 1] = static_cast(v1); + out[t * 3 + 2] = static_cast(v2); + } + } + + // Extract edge indices from a Mesh<2>. + void extract_edge_indices(const quiver::Mesh<2> &mesh, + std::vector &out) { + const auto &skel1 = mesh.skeleton<1>(); + const auto &skel0 = mesh.skeleton<0>(); + quiver::attributes::IncidentFaceIndices<1, 0, 2> edge_verts(skel1, + skel0); + + std::size_t n_edges = edge_verts.size(); + out.resize(n_edges * 2); + for (std::size_t e = 0; e < n_edges; ++e) { + auto [v0, v1] = edge_verts.get_indices(e); + out[e * 2 + 0] = static_cast(v0); + out[e * 2 + 1] = static_cast(v1); + } + } + + // Extract edge indices from a Mesh<1> (edge mesh — no triangle topology). + void extract_edge_indices_1d(const quiver::Mesh<1> &mesh, + std::vector &out) { + const auto &skel0 = mesh.skeleton<0>(); + quiver::attributes::IncidentFaceIndices<1, 0, 1> edge_verts(skel0); + + std::size_t n_edges = edge_verts.size(); + out.resize(n_edges * 2); + for (std::size_t e = 0; e < n_edges; ++e) { + auto [v0, v1] = edge_verts.get_indices(e); + out[e * 2 + 0] = static_cast(v0); + out[e * 2 + 1] = static_cast(v1); + } + } + + // Extract triangle indices from a Mesh<3> (tetrahedra → triangle faces). + void extract_tri_indices_3d(const quiver::Mesh<3> &mesh, + std::vector &out) { + const auto &skel2 = mesh.skeleton<2>(); + const auto &skel0 = mesh.skeleton<0>(); + quiver::attributes::IncidentFaceIndices<2, 0, 3> tri_verts(skel2, + skel0); + + std::size_t n_tris = tri_verts.size(); + out.resize(n_tris * 3); + for (std::size_t t = 0; t < n_tris; ++t) { + auto [v0, v1, v2] = tri_verts.get_indices(t); + out[t * 3 + 0] = static_cast(v0); + out[t * 3 + 1] = static_cast(v1); + out[t * 3 + 2] = static_cast(v2); + } + } + + // Extract edge indices from a Mesh<3> (tetrahedra → edges). + void extract_edge_indices_3d(const quiver::Mesh<3> &mesh, + std::vector &out) { + const auto &skel1 = mesh.skeleton<1>(); + const auto &skel0 = mesh.skeleton<0>(); + quiver::attributes::IncidentFaceIndices<1, 0, 3> edge_verts(skel1, + skel0); + + std::size_t n_edges = edge_verts.size(); + out.resize(n_edges * 2); + for (std::size_t e = 0; e < n_edges; ++e) { + auto [v0, v1] = edge_verts.get_indices(e); + out[e * 2 + 0] = static_cast(v0); + out[e * 2 + 1] = static_cast(v1); + } + } + +} // anonymous namespace + +// ── MeshData ──────────────────────────────────────────────────────── + +MeshData::MeshData() = default; + +void MeshData::set_mesh(std::shared_ptr mesh) { + _mesh = std::move(mesh); + _position.clear(); + _normal.clear(); + _scalar.clear(); + _scalar_component = -1; + _tri_indices.clear(); + _edge_indices.clear(); + _discovered.clear(); + + if (!_mesh) { + ++_version; + return; + } + + // Build all sub-skeletons so topology queries work. + _mesh->build_all_skeletons(); + + // Enumerate attributes. + discover_attributes(); + + // Auto-assign roles by convention name. + for (const auto &da : _discovered) { + if (da.name == "vertex_positions" && !_position.is_bound()) { + assign_position(da.handle); + } else if (da.name == "vertex_normals" && !_normal.is_bound()) { + assign_normal(da.handle); } } + // Extract topology indices. + extract_topology_indices(); + ++_version; } -void MeshData::set_edge_indices(std::span indices) { - _h_edge_indices.attribute().mutable_data().assign( - indices.begin(), indices.end()); - _explicit_edges = true; +// ── Attribute discovery ───────────────────────────────────────────── + +void MeshData::discover_attributes() { + _discovered.clear(); + if (!_mesh) return; + + auto entries = _mesh->attribute_entries(); + for (const auto &entry : entries) { + if (!entry.attribute) continue; + + auto *stored = dynamic_cast( + entry.attribute.get()); + if (!stored) continue; + + // Create a type-erased handle to this attribute. + auto handle = qattr::ConstTypeErasedAttributeHandle(*stored); + + // Probe the concrete type via dynamic_cast. + auto probe = probe_type(handle); + if (probe.component_count == 0) continue; // unrecognised type + + DiscoveredAttribute da; + da.handle = handle; + da.name = entry.name; + da.dimension = entry.dim; + da.type_id = probe.type_id; + da.element_size = probe.element_size; + da.count = stored->size(); + da.component_count = probe.component_count; + da.is_floating_point = probe.is_floating_point; + + _discovered.push_back(std::move(da)); + } +} + +// ── Topology index extraction ─────────────────────────────────────── + +void MeshData::extract_topology_indices() { + _tri_indices.clear(); + _edge_indices.clear(); + if (!_mesh) return; + + int8_t dim = _mesh->dimension(); + + if (dim == 2) { + auto &mesh2 = _mesh->as<2, true>(); + extract_tri_indices(mesh2, _tri_indices); + extract_edge_indices(mesh2, _edge_indices); + } else if (dim == 3) { + auto &mesh3 = _mesh->as<3, true>(); + extract_tri_indices_3d(mesh3, _tri_indices); + extract_edge_indices_3d(mesh3, _edge_indices); + } else if (dim == 1) { + auto &mesh1 = _mesh->as<1, true>(); + extract_edge_indices_1d(mesh1, _edge_indices); + } +} + +// ── Role assignment ───────────────────────────────────────────────── + +void MeshData::assign_position(qattr::ConstTypeErasedAttributeHandle handle) { + _position = build_vector_binding(handle); ++_version; } -void MeshData::set_vertex_colors(std::span colors) { - _h_vertex_colors.attribute().mutable_data().assign( - colors.begin(), colors.end()); +void MeshData::assign_normal(qattr::ConstTypeErasedAttributeHandle handle) { + _normal = build_vector_binding(handle); ++_version; } -void MeshData::set_scalar_field(std::span scalars) { - _h_scalar_field.attribute().mutable_data().assign( - scalars.begin(), scalars.end()); +void MeshData::assign_scalar(qattr::ConstTypeErasedAttributeHandle handle, + int component) { + _scalar_component = component; + _scalar = build_scalar_binding(handle, component); ++_version; } -// ── Geometry accessors ────────────────────────────────────────────── +void MeshData::clear_position() { + _position.clear(); + ++_version; +} -std::span MeshData::positions() const { - const auto &d = _h_positions.attribute().data(); - return { d.data(), d.size() }; +void MeshData::clear_normal() { + _normal.clear(); + ++_version; } -std::span MeshData::normals() const { - const auto &d = _h_normals.attribute().data(); - return { d.data(), d.size() }; +void MeshData::clear_scalar() { + _scalar.clear(); + _scalar_component = -1; + ++_version; } +// ── Accessors ─────────────────────────────────────────────────────── + std::span MeshData::triangle_indices() const { - const auto &d = _h_triangle_indices.attribute().data(); - return { d.data(), d.size() }; + return _tri_indices; } std::span MeshData::edge_indices() const { - const auto &d = _h_edge_indices.attribute().data(); - return { d.data(), d.size() }; + return _edge_indices; } -std::span MeshData::vertex_colors() const { - const auto &d = _h_vertex_colors.attribute().data(); - return { d.data(), d.size() }; +std::size_t MeshData::vertex_count() const { + if (_position.is_bound()) return _position.size(); + return 0; } -std::span MeshData::scalar_field() const { - const auto &d = _h_scalar_field.attribute().data(); - return { d.data(), d.size() }; -} +// ── Static helpers: build role bindings ────────────────────────────── -bool MeshData::has_positions() const { return _h_positions.size() > 0; } -bool MeshData::has_normals() const { return _h_normals.size() > 0; } -bool MeshData::has_triangle_indices() const { return _h_triangle_indices.size() > 0; } -bool MeshData::has_edge_indices() const { return _h_edge_indices.size() > 0; } -bool MeshData::has_vertex_colors() const { return _h_vertex_colors.size() > 0; } -bool MeshData::has_scalar_field() const { return _h_scalar_field.size() > 0; } +RoleBinding MeshData::build_vector_binding( + qattr::ConstTypeErasedAttributeHandle handle) { + RoleBinding binding; + if (!handle.valid()) return binding; -std::size_t MeshData::vertex_count() const { return _h_positions.size(); } -std::size_t MeshData::triangle_count() const { return _h_triangle_indices.size() / 3; } -std::size_t MeshData::edge_count() const { return _h_edge_indices.size() / 2; } + auto probe = probe_type(handle); + if (probe.component_count == 0 || !probe.is_floating_point) { + spdlog::warn( + "MeshData: attribute type is not a supported floating-point vector " + "type"); + return binding; + } -// ── Private: edge derivation ──────────────────────────────────────── + binding.source = handle; -void MeshData::derive_edges_from_topology() { - if (!_topology) return; + if (is_float_type(probe.type_id)) { + // Already float — gpu_ready points directly at source. + binding.gpu_ready = handle; + binding.component_count = probe.component_count; + } else if (is_double_type(probe.type_id)) { + // Double → float conversion needed. + auto cr = build_float_cache_vector(handle, probe.type_id); + if (cr.owned) { + binding.gpu_ready = cr.handle; + binding.cached = std::move(cr.owned); + binding.component_count = cr.component_count; + } else { + spdlog::warn( + "MeshData: failed to build float cache for double attribute"); + return {}; + } + } - auto &mesh = *_topology; - const auto &edge_skel = mesh.build_skeleton<1>(); - const auto &vert_skel = mesh.skeleton<0>(); + return binding; +} - quiver::attributes::IncidentFaceIndices<1, 0, 2> edge_verts( - edge_skel, vert_skel); - std::size_t ne = edge_skel.size(); +RoleBinding + MeshData::build_scalar_binding(qattr::ConstTypeErasedAttributeHandle handle, + int component) { + RoleBinding binding; + if (!handle.valid()) return binding; - auto &edge_data = _h_edge_indices.attribute().mutable_data(); - edge_data.resize(ne * 2); - for (std::size_t e = 0; e < ne; ++e) { - auto [v0, v1] = edge_verts.get_indices(e); - edge_data[e * 2 + 0] = static_cast(v0); - edge_data[e * 2 + 1] = static_cast(v1); + auto probe = probe_type(handle); + if (probe.component_count == 0) { + spdlog::warn("MeshData: unrecognised attribute type for scalar role"); + return binding; } + + binding.source = handle; + + // Simple case: already a single float, no component extraction. + if (probe.type_id == typeid(float) && component < 0) { + binding.gpu_ready = handle; + binding.component_count = 1; + return binding; + } + + // Need conversion or component extraction. + auto cr = build_float_cache_scalar(handle, probe.type_id, component); + if (cr.owned) { + binding.gpu_ready = cr.handle; + binding.cached = std::move(cr.owned); + binding.component_count = 1; + } else if (probe.type_id == typeid(float) && component < 0) { + // build_float_cache_scalar returns empty for float with no + // component extraction — use source directly. + binding.gpu_ready = handle; + binding.component_count = 1; + } else { + spdlog::warn( + "MeshData: failed to build float cache for scalar attribute"); + return {}; + } + + return binding; } -}// namespace balsa::scene_graph +} // namespace balsa::scene_graph diff --git a/visualization/src/vulkan/image_pipeline.cpp b/visualization/src/vulkan/image_pipeline.cpp new file mode 100644 index 0000000..636fdbf --- /dev/null +++ b/visualization/src/vulkan/image_pipeline.cpp @@ -0,0 +1,418 @@ +#include "balsa/visualization/vulkan/image_pipeline.hpp" +#include "balsa/visualization/shaders/abstract_shader.hpp" +#include "balsa/visualization/vulkan/film.hpp" + +#include +#include + +namespace balsa::visualization::vulkan { + +// ── ImagePipelineManager lifecycle ────────────────────────────────── + +ImagePipelineManager::~ImagePipelineManager() { release(); } + +ImagePipelineManager::ImagePipelineManager(ImagePipelineManager &&o) noexcept + : _device(o._device), _film(o._film), + _descriptor_set_layout(o._descriptor_set_layout), + _pipeline_layout(o._pipeline_layout), _descriptor_pool(o._descriptor_pool), + _pipeline(o._pipeline), _cached_render_pass(o._cached_render_pass), + _cached_msaa_samples(o._cached_msaa_samples), + _cached_depth_test(o._cached_depth_test), _initialized(o._initialized) { + o._device = vk::Device{}; + o._film = nullptr; + o._descriptor_set_layout = vk::DescriptorSetLayout{}; + o._pipeline_layout = vk::PipelineLayout{}; + o._descriptor_pool = vk::DescriptorPool{}; + o._pipeline = vk::Pipeline{}; + o._initialized = false; +} + +auto ImagePipelineManager::operator=(ImagePipelineManager &&o) noexcept + -> ImagePipelineManager & { + if (this != &o) { + release(); + _device = o._device; + _film = o._film; + _descriptor_set_layout = o._descriptor_set_layout; + _pipeline_layout = o._pipeline_layout; + _descriptor_pool = o._descriptor_pool; + _pipeline = o._pipeline; + _cached_render_pass = o._cached_render_pass; + _cached_msaa_samples = o._cached_msaa_samples; + _cached_depth_test = o._cached_depth_test; + _initialized = o._initialized; + o._device = vk::Device{}; + o._film = nullptr; + o._descriptor_set_layout = vk::DescriptorSetLayout{}; + o._pipeline_layout = vk::PipelineLayout{}; + o._descriptor_pool = vk::DescriptorPool{}; + o._pipeline = vk::Pipeline{}; + o._initialized = false; + } + return *this; +} + +auto ImagePipelineManager::init(Film &film, uint32_t max_descriptor_sets) -> void { + if (_initialized) { release(); } + _film = &film; + _device = film.device(); + + create_descriptor_set_layout(); + create_pipeline_layout(); + create_descriptor_pool(max_descriptor_sets); + + _initialized = true; + spdlog::info("ImagePipelineManager: initialized"); +} + +auto ImagePipelineManager::release() -> void { + if (!_initialized) { return; } + if (_device) { + _device.waitIdle(); + + if (_pipeline) { + _device.destroyPipeline(_pipeline); + _pipeline = vk::Pipeline{}; + } + + if (_descriptor_pool) { + _device.destroyDescriptorPool(_descriptor_pool); + _descriptor_pool = vk::DescriptorPool{}; + } + if (_pipeline_layout) { + _device.destroyPipelineLayout(_pipeline_layout); + _pipeline_layout = vk::PipelineLayout{}; + } + if (_descriptor_set_layout) { + _device.destroyDescriptorSetLayout(_descriptor_set_layout); + _descriptor_set_layout = vk::DescriptorSetLayout{}; + } + } + _device = vk::Device{}; + _film = nullptr; + _initialized = false; +} + +auto ImagePipelineManager::invalidate_pipeline() -> void { + if (!_initialized) return; + if (_device && _pipeline) { + _device.waitIdle(); + _device.destroyPipeline(_pipeline); + _pipeline = vk::Pipeline{}; + } + _cached_render_pass = 0; + _cached_msaa_samples = 0; + _cached_depth_test = false; + spdlog::info("ImagePipelineManager: pipeline invalidated"); +} + +// ── Descriptor set layout / pipeline layout / pool ────────────────── + +auto ImagePipelineManager::create_descriptor_set_layout() -> void { + // binding 0: ImageTransformUBO (vertex stage) + // binding 1: ImageParamsUBO (fragment stage) + // binding 2: combined image sampler (fragment stage) + std::array bindings; + + auto &transform_binding = bindings[0]; + transform_binding.setBinding(0); + transform_binding.setDescriptorType(vk::DescriptorType::eUniformBuffer); + transform_binding.setDescriptorCount(1); + transform_binding.setStageFlags(vk::ShaderStageFlagBits::eVertex); + + auto ¶ms_binding = bindings[1]; + params_binding.setBinding(1); + params_binding.setDescriptorType(vk::DescriptorType::eUniformBuffer); + params_binding.setDescriptorCount(1); + params_binding.setStageFlags(vk::ShaderStageFlagBits::eFragment); + + auto &sampler_binding = bindings[2]; + sampler_binding.setBinding(2); + sampler_binding.setDescriptorType( + vk::DescriptorType::eCombinedImageSampler); + sampler_binding.setDescriptorCount(1); + sampler_binding.setStageFlags(vk::ShaderStageFlagBits::eFragment); + + vk::DescriptorSetLayoutCreateInfo ci; + ci.setBindings(bindings); + _descriptor_set_layout = _device.createDescriptorSetLayout(ci); +} + +auto ImagePipelineManager::create_pipeline_layout() -> void { + vk::PipelineLayoutCreateInfo ci; + ci.setSetLayouts(_descriptor_set_layout); + ci.setPushConstantRangeCount(0); + _pipeline_layout = _device.createPipelineLayout(ci); +} + +auto ImagePipelineManager::create_descriptor_pool(uint32_t max_sets) -> void { + // Each set has 2 uniform buffers + 1 combined image sampler. + std::array pool_sizes; + pool_sizes[0].setType(vk::DescriptorType::eUniformBuffer); + pool_sizes[0].setDescriptorCount(max_sets * 2); // 2 UBOs per set + pool_sizes[1].setType(vk::DescriptorType::eCombinedImageSampler); + pool_sizes[1].setDescriptorCount(max_sets); // 1 sampler per set + + vk::DescriptorPoolCreateInfo ci; + ci.setFlags(vk::DescriptorPoolCreateFlagBits::eFreeDescriptorSet); + ci.setMaxSets(max_sets); + ci.setPoolSizes(pool_sizes); + _descriptor_pool = _device.createDescriptorPool(ci); +} + +// ── Descriptor set allocation / writing / freeing ──────────────────── + +auto ImagePipelineManager::allocate_descriptor_set() -> vk::DescriptorSet { + if (!_initialized) { + throw std::runtime_error("ImagePipelineManager: not initialized"); + } + vk::DescriptorSetAllocateInfo ai; + ai.setDescriptorPool(_descriptor_pool); + ai.setSetLayouts(_descriptor_set_layout); + auto sets = _device.allocateDescriptorSets(ai); + return sets[0]; +} + +auto ImagePipelineManager::write_descriptor_set(vk::DescriptorSet ds, + vk::Buffer transform_buffer, + vk::DeviceSize transform_size, + vk::Buffer params_buffer, + vk::DeviceSize params_size, + vk::ImageView image_view, + vk::Sampler sampler) -> void { + vk::DescriptorBufferInfo transform_info; + transform_info.setBuffer(transform_buffer); + transform_info.setOffset(0); + transform_info.setRange(transform_size); + + vk::DescriptorBufferInfo params_info; + params_info.setBuffer(params_buffer); + params_info.setOffset(0); + params_info.setRange(params_size); + + vk::DescriptorImageInfo image_info; + image_info.setImageLayout(vk::ImageLayout::eShaderReadOnlyOptimal); + image_info.setImageView(image_view); + image_info.setSampler(sampler); + + std::array writes; + + auto &w0 = writes[0]; + w0.setDstSet(ds); + w0.setDstBinding(0); + w0.setDstArrayElement(0); + w0.setDescriptorType(vk::DescriptorType::eUniformBuffer); + w0.setDescriptorCount(1); + w0.setPBufferInfo(&transform_info); + + auto &w1 = writes[1]; + w1.setDstSet(ds); + w1.setDstBinding(1); + w1.setDstArrayElement(0); + w1.setDescriptorType(vk::DescriptorType::eUniformBuffer); + w1.setDescriptorCount(1); + w1.setPBufferInfo(¶ms_info); + + auto &w2 = writes[2]; + w2.setDstSet(ds); + w2.setDstBinding(2); + w2.setDstArrayElement(0); + w2.setDescriptorType(vk::DescriptorType::eCombinedImageSampler); + w2.setDescriptorCount(1); + w2.setPImageInfo(&image_info); + + _device.updateDescriptorSets(writes, {}); +} + +auto ImagePipelineManager::free_descriptor_set(vk::DescriptorSet ds) -> void { + if (!_initialized || !ds) return; + _device.freeDescriptorSets(_descriptor_pool, {ds}); +} + +// ── Pipeline access ───────────────────────────────────────────────── + +auto ImagePipelineManager::get_or_create(Film &film) -> vk::Pipeline { + if (!_initialized) { + throw std::runtime_error("ImagePipelineManager: not initialized"); + } + + // Check if the cached pipeline is still valid for this render pass. + uint64_t rp = reinterpret_cast( + static_cast(film.default_render_pass())); + uint32_t msaa = static_cast(film.sample_count()); + bool depth = film.has_depth_stencil(); + + if (_pipeline && rp == _cached_render_pass && msaa == _cached_msaa_samples + && depth == _cached_depth_test) { + return _pipeline; + } + + // Invalidate and recreate. + if (_pipeline) { + _device.destroyPipeline(_pipeline); + _pipeline = vk::Pipeline{}; + } + + _cached_render_pass = rp; + _cached_msaa_samples = msaa; + _cached_depth_test = depth; + + _pipeline = create_pipeline(); + return _pipeline; +} + +// ── Pipeline creation ─────────────────────────────────────────────── + +auto ImagePipelineManager::create_pipeline() -> vk::Pipeline { + spdlog::info("ImagePipelineManager: creating pipeline"); + + // Compile shaders from Qt resources. + shaders::AbstractShader shader_compiler; + auto vert_spv = shader_compiler.compile_glsl_from_path( + ":/glsl/image.vert", shaders::AbstractShader::ShaderType::Vertex); + auto frag_spv = shader_compiler.compile_glsl_from_path( + ":/glsl/image.frag", shaders::AbstractShader::ShaderType::Fragment); + + if (vert_spv.empty() || frag_spv.empty()) { + spdlog::error( + "ImagePipelineManager: shader compilation failed (empty SPIR-V)"); + return vk::Pipeline{}; + } + + // Create shader modules. + auto create_shader_module = + [&](const std::vector &spv) -> vk::ShaderModule { + vk::ShaderModuleCreateInfo ci; + ci.setCodeSize(sizeof(uint32_t) * spv.size()); + ci.setPCode(spv.data()); + return _device.createShaderModule(ci); + }; + + vk::ShaderModule vert_module = create_shader_module(vert_spv); + vk::ShaderModule frag_module = create_shader_module(frag_spv); + + // Shader stages. + const std::string entry = "main"; + vk::PipelineShaderStageCreateInfo shader_stages[2]; + { + auto &vs = shader_stages[0]; + vs.setStage(vk::ShaderStageFlagBits::eVertex); + vs.setModule(vert_module); + vs.setPName(entry.c_str()); + + auto &fs = shader_stages[1]; + fs.setStage(vk::ShaderStageFlagBits::eFragment); + fs.setModule(frag_module); + fs.setPName(entry.c_str()); + } + + // ── Vertex input: EMPTY (fullscreen triangle, no vertex buffer) ── + + vk::PipelineVertexInputStateCreateInfo vertex_input; + // No binding or attribute descriptions. + + // ── Input assembly ────────────────────────────────────────────── + + vk::PipelineInputAssemblyStateCreateInfo input_assembly; + input_assembly.setTopology(vk::PrimitiveTopology::eTriangleList); + input_assembly.setPrimitiveRestartEnable(VK_FALSE); + + // ── Viewport / scissor (dynamic) ──────────────────────────────── + + vk::PipelineViewportStateCreateInfo viewport_state; + viewport_state.setViewportCount(1); + viewport_state.setScissorCount(1); + + // ── Rasterization ─────────────────────────────────────────────── + + vk::PipelineRasterizationStateCreateInfo rasterization; + rasterization.setDepthClampEnable(VK_FALSE); + rasterization.setRasterizerDiscardEnable(VK_FALSE); + rasterization.setPolygonMode(vk::PolygonMode::eFill); + rasterization.setCullMode(vk::CullModeFlagBits::eNone); + rasterization.setFrontFace(vk::FrontFace::eCounterClockwise); + rasterization.setDepthBiasEnable(VK_FALSE); + rasterization.setLineWidth(1.0f); + + // ── Multisample ───────────────────────────────────────────────── + + vk::PipelineMultisampleStateCreateInfo multisampling; + multisampling.setRasterizationSamples( + static_cast(_cached_msaa_samples)); + multisampling.setSampleShadingEnable(VK_FALSE); + multisampling.setMinSampleShading(1.0f); + + // ── Depth / stencil ───────────────────────────────────────────── + + vk::PipelineDepthStencilStateCreateInfo depth_stencil; + depth_stencil.setDepthTestEnable(VK_FALSE); + depth_stencil.setDepthWriteEnable(VK_FALSE); + depth_stencil.setStencilTestEnable(VK_FALSE); + + // ── Color blending ────────────────────────────────────────────── + + vk::PipelineColorBlendAttachmentState color_blend_attachment; + color_blend_attachment.setColorWriteMask( + vk::ColorComponentFlagBits::eR | vk::ColorComponentFlagBits::eG + | vk::ColorComponentFlagBits::eB | vk::ColorComponentFlagBits::eA); + color_blend_attachment.setBlendEnable(VK_TRUE); + color_blend_attachment.setSrcColorBlendFactor(vk::BlendFactor::eSrcAlpha); + color_blend_attachment.setDstColorBlendFactor( + vk::BlendFactor::eOneMinusSrcAlpha); + color_blend_attachment.setColorBlendOp(vk::BlendOp::eAdd); + color_blend_attachment.setSrcAlphaBlendFactor(vk::BlendFactor::eOne); + color_blend_attachment.setDstAlphaBlendFactor(vk::BlendFactor::eZero); + color_blend_attachment.setAlphaBlendOp(vk::BlendOp::eAdd); + + vk::PipelineColorBlendStateCreateInfo color_blend; + color_blend.setLogicOpEnable(VK_FALSE); + color_blend.setAttachmentCount(1); + color_blend.setPAttachments(&color_blend_attachment); + + // ── Dynamic state ─────────────────────────────────────────────── + + std::array dynamic_states = { + vk::DynamicState::eViewport, + vk::DynamicState::eScissor, + }; + vk::PipelineDynamicStateCreateInfo dynamic_state; + dynamic_state.setDynamicStates(dynamic_states); + + // ── Assemble ──────────────────────────────────────────────────── + + vk::RenderPass rp{reinterpret_cast(_cached_render_pass)}; + + vk::GraphicsPipelineCreateInfo pipeline_info; + pipeline_info.setStageCount(2); + pipeline_info.setPStages(shader_stages); + pipeline_info.setPVertexInputState(&vertex_input); + pipeline_info.setPInputAssemblyState(&input_assembly); + pipeline_info.setPViewportState(&viewport_state); + pipeline_info.setPRasterizationState(&rasterization); + pipeline_info.setPMultisampleState(&multisampling); + pipeline_info.setPDepthStencilState(&depth_stencil); + pipeline_info.setPColorBlendState(&color_blend); + pipeline_info.setPDynamicState(&dynamic_state); + pipeline_info.setLayout(_pipeline_layout); + pipeline_info.setRenderPass(rp); + pipeline_info.setSubpass(0); + pipeline_info.setBasePipelineHandle(VK_NULL_HANDLE); + + vk::Pipeline result; + auto res = _device.createGraphicsPipeline(VK_NULL_HANDLE, pipeline_info); + if (res.result == vk::Result::eSuccess) { + result = res.value; + spdlog::info("ImagePipelineManager: pipeline created successfully"); + } else { + spdlog::error( + "ImagePipelineManager: failed to create graphics pipeline"); + } + + // Cleanup shader modules. + _device.destroyShaderModule(vert_module); + _device.destroyShaderModule(frag_module); + + return result; +} + +} // namespace balsa::visualization::vulkan diff --git a/visualization/src/vulkan/image_scene.cpp b/visualization/src/vulkan/image_scene.cpp new file mode 100644 index 0000000..3ce51d0 --- /dev/null +++ b/visualization/src/vulkan/image_scene.cpp @@ -0,0 +1,209 @@ +#include "balsa/visualization/vulkan/image_scene.hpp" +#include "balsa/visualization/vulkan/film.hpp" +#include "balsa/visualization/vulkan/vulkan_image_drawable.hpp" + +#include +#include +#include + +namespace balsa::visualization::vulkan { + +// ── Constructor / Destructor ──────────────────────────────────────── + +ImageScene::ImageScene() : _scene_root("Root") { + // Create a camera Object as a child of the root. + auto &cam_obj = _scene_root.add_child("Camera"); + cam_obj.permanent = true; + _camera_object = &cam_obj; + _camera = &cam_obj.emplace_feature(); + + // Start with a sensible orthographic projection. + // Will be updated by fit_to_window() once an image is loaded. + _camera->set_orthographic(-1.0f, 1.0f, -1.0f, 1.0f, -1.0f, 1.0f); +} + +ImageScene::~ImageScene() { release_vulkan_resources(); } + +// ── SceneBase overrides ───────────────────────────────────────────── + +auto ImageScene::initialize(Film &film) -> void { + SceneBase::initialize(film); + _film = &film; + + const uint32_t max_sets = + 4 * static_cast(film.concurrent_frame_count()); + _pipeline_manager.init(film, max_sets); + _initialized = true; + + // Init any VulkanImageDrawables that were added before initialize(). + for (auto *drawable : _drawable_group) { + auto *vid = dynamic_cast(drawable); + if (vid && !vid->is_initialized()) { vid->init(film); } + } + + spdlog::info("ImageScene initialized with {} drawable(s)", + _drawable_group.size()); +} + +auto ImageScene::draw(Film &film) -> void { + if (!_initialized) return; + + for (auto *drawable : _drawable_group) { + auto *vid = dynamic_cast(drawable); + if (!vid) continue; + vid->draw(*_camera, film); + } +} + +auto ImageScene::release_vulkan_resources() -> void { + if (!_initialized) return; + + spdlog::debug("ImageScene: releasing Vulkan resources ({} drawables)", + _drawable_group.size()); + + // Release all VulkanImageDrawables first (they reference pipeline + // manager's descriptor pool). + for (auto *drawable : _drawable_group) { + auto *vid = dynamic_cast(drawable); + if (vid) vid->release(); + } + + // Then release the pipeline manager. + _pipeline_manager.release(); + + _film = nullptr; + _initialized = false; +} + +// ── Image management ──────────────────────────────────────────────── + +auto ImageScene::ensure_image_object() -> void { + if (_image_object) return; + + auto &obj = _scene_root.add_child("Image"); + _image_object = &obj; + obj.emplace_feature(); + auto &vid = obj.emplace_feature(_drawable_group, + _pipeline_manager); + + if (_initialized && _film) { vid.init(*_film); } +} + +auto ImageScene::set_image(uint32_t width, + uint32_t height, + scene_graph::ImageData::Format format, + std::span pixels) -> void { + ensure_image_object(); + auto *img = _image_object->find_feature(); + img->set_pixels(width, height, format, pixels); + update_mvp(); +} + +auto ImageScene::set_image_rgba8(uint32_t width, + uint32_t height, + std::span rgba) -> void { + ensure_image_object(); + auto *img = _image_object->find_feature(); + img->set_pixels_rgba8(width, height, rgba); + update_mvp(); +} + +auto ImageScene::set_image_rgbaf32(uint32_t width, + uint32_t height, + std::span rgba) -> void { + ensure_image_object(); + auto *img = _image_object->find_feature(); + img->set_pixels_rgbaf32(width, height, rgba); + update_mvp(); +} + +auto ImageScene::image_data() -> scene_graph::ImageData * { + if (!_image_object) return nullptr; + return _image_object->find_feature(); +} + +auto ImageScene::image_data() const -> const scene_graph::ImageData * { + if (!_image_object) return nullptr; + return _image_object->find_feature(); +} + +auto ImageScene::has_image() const -> bool { + return _image_object != nullptr + && _image_object->find_feature() != nullptr; +} + +// ── 2D navigation ─────────────────────────────────────────────────── + +auto ImageScene::set_zoom(float zoom) -> void { + _zoom = std::max(0.01f, zoom); + update_mvp(); +} + +auto ImageScene::set_pan(float x, float y) -> void { + _pan_x = x; + _pan_y = y; + update_mvp(); +} + +auto ImageScene::fit_to_window() -> void { + _zoom = 1.0f; + _pan_x = 0.0f; + _pan_y = 0.0f; + update_mvp(); +} + +// ── Camera accessors ──────────────────────────────────────────────── + +auto ImageScene::camera() -> scene_graph::Camera & { return *_camera; } +auto ImageScene::camera() const -> const scene_graph::Camera & { return *_camera; } + +// ── Private: MVP computation ──────────────────────────────────────── + +auto ImageScene::update_mvp() -> void { + if (!_image_object) return; + + auto *vid = _image_object->find_feature(); + if (!vid) return; + + // The fullscreen triangle shader maps gl_VertexIndex {0,1,2} to a + // triangle covering the entire [-1,1] clip space and UV [0,1]. + // With an identity MVP, the image fills the viewport. + // + // To apply pan/zoom, we construct an MVP that: + // - Scales by _zoom (values > 1 enlarge the image) + // - Translates by (_pan_x, _pan_y) in NDC + // + // The vertex shader applies: + // gl_Position = mvp * vec4(clip_xy, 0.0, 1.0) + // + // So the MVP is a simple 2D scale + translate matrix. + + scene_graph::Mat4f mvp; + // Start with identity + mvp(0, 0) = _zoom; + mvp(1, 1) = _zoom; + mvp(2, 2) = 1.0f; + mvp(3, 3) = 1.0f; + + // Off-diagonals zero + mvp(0, 1) = 0.0f; + mvp(0, 2) = 0.0f; + mvp(0, 3) = 0.0f; + mvp(1, 0) = 0.0f; + mvp(1, 2) = 0.0f; + mvp(1, 3) = 0.0f; + mvp(2, 0) = 0.0f; + mvp(2, 1) = 0.0f; + mvp(2, 3) = 0.0f; + mvp(3, 0) = 0.0f; + mvp(3, 1) = 0.0f; + mvp(3, 2) = 0.0f; + + // Translation: column 3 (column-major) + mvp(0, 3) = _pan_x; + mvp(1, 3) = _pan_y; + + vid->set_mvp_override(mvp); +} + +} // namespace balsa::visualization::vulkan diff --git a/visualization/src/vulkan/imgui/image_controls_panel.cpp b/visualization/src/vulkan/imgui/image_controls_panel.cpp new file mode 100644 index 0000000..df4aba3 --- /dev/null +++ b/visualization/src/vulkan/imgui/image_controls_panel.cpp @@ -0,0 +1,109 @@ +#include "balsa/visualization/vulkan/imgui/image_controls_panel.hpp" +#include "balsa/scene_graph/ImageData.hpp" +#include "balsa/visualization/vulkan/image_scene.hpp" + +#include +#include + +namespace balsa::visualization::vulkan::imgui { + +auto draw_image_controls(ImageScene &scene, ImagePanelState &state) -> bool { + bool changed = false; + + if (!state.show_controls) return false; + + if (ImGui::Begin("Image Controls", &state.show_controls)) { + auto *img = scene.image_data(); + + // ── Image info ────────────────────────────────────────────── + if (img && img->has_pixels()) { + ImGui::Text("Size: %u x %u", img->width(), img->height()); + const char *fmt_name = + (img->format() == scene_graph::ImageData::Format::RGBAF32) + ? "Float32 HDR" + : "RGBA8 sRGB"; + ImGui::Text("Format: %s", fmt_name); + ImGui::Separator(); + } else { + ImGui::TextDisabled("No image loaded"); + ImGui::End(); + return false; + } + + // ── Tone Mapping ──────────────────────────────────────────── + if (ImGui::CollapsingHeader("Tone Mapping", + ImGuiTreeNodeFlags_DefaultOpen)) { + float exposure = img->exposure(); + if (ImGui::SliderFloat("Exposure (EV)", &exposure, -10.0f, 10.0f)) { + img->set_exposure(exposure); + changed = true; + } + + float gamma = img->gamma(); + if (ImGui::SliderFloat("Gamma", &gamma, 0.1f, 5.0f)) { + img->set_gamma(gamma); + changed = true; + } + + if (ImGui::Button("Reset Tone Mapping")) { + img->set_exposure(0.0f); + img->set_gamma(2.2f); + changed = true; + } + } + + // ── Channel Mode ──────────────────────────────────────────── + if (ImGui::CollapsingHeader("Channel Display", + ImGuiTreeNodeFlags_DefaultOpen)) { + static constexpr const char *channel_names[] = { + "RGBA", "Red", "Green", "Blue", "Alpha", "Luminance"}; + int current = static_cast(img->channel_mode()); + if (ImGui::Combo("Channel", ¤t, channel_names, 6)) { + img->set_channel_mode( + static_cast(current)); + changed = true; + } + } + + // ── Navigation ────────────────────────────────────────────── + if (ImGui::CollapsingHeader("Navigation", + ImGuiTreeNodeFlags_DefaultOpen)) { + float zoom = scene.zoom(); + if (ImGui::SliderFloat("Zoom", + &zoom, + 0.01f, + 50.0f, + "%.2fx", + ImGuiSliderFlags_Logarithmic)) { + scene.set_zoom(zoom); + changed = true; + } + + float pan_x = scene.pan_x(); + float pan_y = scene.pan_y(); + bool pan_changed = false; + pan_changed |= ImGui::SliderFloat("Pan X", &pan_x, -2.0f, 2.0f); + pan_changed |= ImGui::SliderFloat("Pan Y", &pan_y, -2.0f, 2.0f); + if (pan_changed) { + scene.set_pan(pan_x, pan_y); + changed = true; + } + + if (ImGui::Button("Fit to Window")) { + scene.fit_to_window(); + changed = true; + } + ImGui::SameLine(); + if (ImGui::Button("1:1 (100%)")) { + scene.set_zoom(1.0f); + scene.set_pan(0.0f, 0.0f); + changed = true; + } + } + } + ImGui::End(); + + return changed; +} + +} // namespace balsa::visualization::vulkan::imgui diff --git a/visualization/src/vulkan/imgui/mesh_controls_panel.cpp b/visualization/src/vulkan/imgui/mesh_controls_panel.cpp index 07a652b..cdc91d8 100644 --- a/visualization/src/vulkan/imgui/mesh_controls_panel.cpp +++ b/visualization/src/vulkan/imgui/mesh_controls_panel.cpp @@ -1,36 +1,39 @@ #include "balsa/visualization/vulkan/imgui/mesh_controls_panel.hpp" -#include "balsa/visualization/vulkan/mesh_scene.hpp" -#include "balsa/visualization/vulkan/mesh_render_state.hpp" -#include "balsa/visualization/colormap_list.hpp" -#include "balsa/scene_graph/Object.hpp" +#include "balsa/scene_graph/BVHData.hpp" #include "balsa/scene_graph/Camera.hpp" -#include "balsa/scene_graph/MeshData.hpp" #include "balsa/scene_graph/Light.hpp" -#include "balsa/scene_graph/BVHData.hpp" +#include "balsa/scene_graph/MeshData.hpp" +#include "balsa/scene_graph/Object.hpp" +#include "balsa/visualization/colormap_list.hpp" +#include "balsa/visualization/vulkan/mesh_render_state.hpp" +#include "balsa/visualization/vulkan/mesh_scene.hpp" -#include #include #include #include +#include #include namespace balsa::visualization::vulkan::imgui { -using visualization::k_colormap_names; -using visualization::k_colormap_count; using visualization::find_colormap_index; +using visualization::k_colormap_count; +using visualization::k_colormap_names; // ── Enum combo helpers ─────────────────────────────────────────────── -static bool combo_shading_model(const char *label, ShadingModel &value, NormalSource normal_source) { - static constexpr const char *items[] = { "Flat", "Gouraud", "Phong" }; +static bool combo_shading_model(const char *label, + ShadingModel &value, + NormalSource normal_source) { + static constexpr const char *items[] = {"Flat", "Gouraud", "Phong"}; int current = static_cast(value); if (ImGui::BeginCombo(label, items[current])) { for (int i = 0; i < 3; ++i) { // Gouraud and Phong require per-vertex normals (FromAttribute). // ComputedInShader gives flat per-triangle normals via dFdx/dFdy, // which makes smooth interpolation meaningless. - bool item_disabled = (i > 0 && normal_source != NormalSource::FromAttribute); + bool item_disabled = + (i > 0 && normal_source != NormalSource::FromAttribute); if (item_disabled) ImGui::BeginDisabled(); bool selected = (i == current); if (ImGui::Selectable(items[i], selected)) { @@ -46,7 +49,8 @@ static bool combo_shading_model(const char *label, ShadingModel &value, NormalSo } static bool combo_color_source(const char *label, ColorSource &value) { - static constexpr const char *items[] = { "Uniform Color", "Per-Vertex Color", "Scalar Field" }; + static constexpr const char *items[] = { + "Uniform Color", "Per-Vertex Color", "Scalar Field"}; int current = static_cast(value); if (ImGui::Combo(label, ¤t, items, 3)) { value = static_cast(current); @@ -55,8 +59,11 @@ static bool combo_color_source(const char *label, ColorSource &value) { return false; } -static bool combo_normal_source(const char *label, NormalSource &value, bool has_normals) { - static constexpr const char *items[] = { "From Attribute", "Computed (Flat)", "None (Unlit)" }; +static bool combo_normal_source(const char *label, + NormalSource &value, + bool has_normals) { + static constexpr const char *items[] = { + "From Attribute", "Computed (Flat)", "None (Unlit)"}; int current = static_cast(value); if (ImGui::BeginCombo(label, items[current])) { for (int i = 0; i < 3; ++i) { @@ -77,7 +84,8 @@ static bool combo_normal_source(const char *label, NormalSource &value, bool has } static bool combo_cull_mode(const char *label, CullMode &value) { - static constexpr const char *items[] = { "None (Show All)", "Back (Exterior)", "Front (Interior)" }; + static constexpr const char *items[] = { + "None (Show All)", "Back (Exterior)", "Front (Interior)"}; int current = static_cast(value); if (ImGui::Combo(label, ¤t, items, 3)) { value = static_cast(current); @@ -94,7 +102,7 @@ static const char *icon_for_object(const scene_graph::Object &obj) { if (obj.find_feature()) return "[C]"; if (obj.find_feature()) return "[B]"; if (obj.find_feature()) return "[M]"; - return "[O]";// Empty / generic object + return "[O]"; // Empty / generic object } // ── Ancestor check (for drag-drop loop prevention) ────────────────── @@ -109,9 +117,8 @@ static bool is_ancestor_of(const scene_graph::Object *candidate, // ── Deep duplicate ────────────────────────────────────────────────── -static std::unique_ptr deep_duplicate( - const scene_graph::Object &src, - MeshScene &scene) { +static std::unique_ptr + deep_duplicate(const scene_graph::Object &src, MeshScene &scene) { auto copy = std::make_unique(src.name + " Copy"); copy->visible = src.visible; copy->selectable = src.selectable; @@ -146,8 +153,10 @@ bool draw_render_state_controls(MeshRenderState &state, bool has_normals) { changed |= state.constrain(has_normals); // ── Shading & Rendering ───────────────────────────────────────── - if (ImGui::CollapsingHeader("Shading & Rendering", ImGuiTreeNodeFlags_DefaultOpen)) { - if (combo_normal_source("Normal Source", state.normal_source, has_normals)) { + if (ImGui::CollapsingHeader("Shading & Rendering", + ImGuiTreeNodeFlags_DefaultOpen)) { + if (combo_normal_source( + "Normal Source", state.normal_source, has_normals)) { // Re-constrain after normal source change. state.constrain(has_normals); changed = true; @@ -156,7 +165,8 @@ bool draw_render_state_controls(MeshRenderState &state, bool has_normals) { // Shading model and two-sided lighting are only meaningful // when normals are active (i.e., lighting is enabled). if (state.normal_source != NormalSource::None) { - if (combo_shading_model("Shading Model", state.shading, state.normal_source)) { + if (combo_shading_model( + "Shading Model", state.shading, state.normal_source)) { changed = true; } changed |= ImGui::Checkbox("Two-Sided Lighting", &state.two_sided); @@ -165,7 +175,8 @@ bool draw_render_state_controls(MeshRenderState &state, bool has_normals) { } // ── Render Layers ─────────────────────────────────────────────── - if (ImGui::CollapsingHeader("Render Layers", ImGuiTreeNodeFlags_DefaultOpen)) { + if (ImGui::CollapsingHeader("Render Layers", + ImGuiTreeNodeFlags_DefaultOpen)) { auto &layers = state.layers; // Solid layer @@ -173,7 +184,10 @@ bool draw_render_state_controls(MeshRenderState &state, bool has_normals) { changed |= ImGui::Checkbox("Solid", &layers.solid.enabled); if (layers.solid.enabled) { ImGui::SameLine(); - changed |= ImGui::ColorEdit4("##solid_color", layers.solid.color, ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_NoLabel); + changed |= ImGui::ColorEdit4("##solid_color", + layers.solid.color, + ImGuiColorEditFlags_NoInputs + | ImGuiColorEditFlags_NoLabel); } ImGui::PopID(); @@ -182,8 +196,12 @@ bool draw_render_state_controls(MeshRenderState &state, bool has_normals) { changed |= ImGui::Checkbox("Wireframe", &layers.wireframe.enabled); if (layers.wireframe.enabled) { ImGui::SameLine(); - changed |= ImGui::ColorEdit4("##wire_color", layers.wireframe.color, ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_NoLabel); - changed |= ImGui::SliderFloat("Wire Width", &layers.wireframe.width, 0.5f, 5.0f); + changed |= ImGui::ColorEdit4("##wire_color", + layers.wireframe.color, + ImGuiColorEditFlags_NoInputs + | ImGuiColorEditFlags_NoLabel); + changed |= ImGui::SliderFloat( + "Wire Width", &layers.wireframe.width, 0.5f, 5.0f); } ImGui::PopID(); @@ -192,8 +210,12 @@ bool draw_render_state_controls(MeshRenderState &state, bool has_normals) { changed |= ImGui::Checkbox("Points", &layers.points.enabled); if (layers.points.enabled) { ImGui::SameLine(); - changed |= ImGui::ColorEdit4("##point_color", layers.points.color, ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_NoLabel); - changed |= ImGui::SliderFloat("Point Size", &layers.points.size, 1.0f, 20.0f); + changed |= ImGui::ColorEdit4("##point_color", + layers.points.color, + ImGuiColorEditFlags_NoInputs + | ImGuiColorEditFlags_NoLabel); + changed |= ImGui::SliderFloat( + "Point Size", &layers.points.size, 1.0f, 20.0f); } ImGui::PopID(); } @@ -209,7 +231,10 @@ bool draw_render_state_controls(MeshRenderState &state, bool has_normals) { if (state.color_source == ColorSource::ScalarField) { // Colormap combo box int cmap_idx = find_colormap_index(state.colormap_name); - if (ImGui::BeginCombo("Colormap", cmap_idx >= 0 ? k_colormap_names[cmap_idx] : state.colormap_name.c_str())) { + if (ImGui::BeginCombo("Colormap", + cmap_idx >= 0 + ? k_colormap_names[cmap_idx] + : state.colormap_name.c_str())) { for (int i = 0; i < k_colormap_count; ++i) { bool selected = (i == cmap_idx); if (ImGui::Selectable(k_colormap_names[i], selected)) { @@ -225,7 +250,10 @@ bool draw_render_state_controls(MeshRenderState &state, bool has_normals) { char buf[128]; std::strncpy(buf, state.colormap_name.c_str(), sizeof(buf) - 1); buf[sizeof(buf) - 1] = '\0'; - if (ImGui::InputText("Custom Colormap", buf, sizeof(buf), ImGuiInputTextFlags_EnterReturnsTrue)) { + if (ImGui::InputText("Custom Colormap", + buf, + sizeof(buf), + ImGuiInputTextFlags_EnterReturnsTrue)) { state.colormap_name = buf; changed = true; } @@ -249,18 +277,24 @@ bool draw_render_state_controls(MeshRenderState &state, bool has_normals) { if (is_lit && ImGui::CollapsingHeader("Material Response")) { ImGui::TextDisabled("How this mesh responds to scene light"); auto &mat = state.material; - changed |= ImGui::SliderFloat("Ambient", &mat.ambient_strength, 0.0f, 1.0f); + changed |= + ImGui::SliderFloat("Ambient", &mat.ambient_strength, 0.0f, 1.0f); if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Base light the mesh receives regardless of light direction"); - changed |= ImGui::SliderFloat("Diffuse", &mat.diffuse_strength, 0.0f, 2.0f); + ImGui::SetTooltip( + "Base light the mesh receives regardless of light direction"); + changed |= + ImGui::SliderFloat("Diffuse", &mat.diffuse_strength, 0.0f, 2.0f); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Intensity of the diffuse lighting response"); - changed |= ImGui::SliderFloat("Specular", &mat.specular_strength, 0.0f, 2.0f); + changed |= + ImGui::SliderFloat("Specular", &mat.specular_strength, 0.0f, 2.0f); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Intensity of the specular highlight"); - changed |= ImGui::SliderFloat("Shininess", &mat.shininess, 1.0f, 256.0f, "%.0f"); + changed |= ImGui::SliderFloat( + "Shininess", &mat.shininess, 1.0f, 256.0f, "%.0f"); if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Sharpness of the specular highlight (higher = tighter)"); + ImGui::SetTooltip( + "Sharpness of the specular highlight (higher = tighter)"); } return changed; @@ -289,9 +323,7 @@ static bool draw_object_node(scene_graph::Object &obj, // ── Visibility checkbox ───────────────────────────────────────── // Compact checkbox before the tree node. ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(2, 2)); - if (ImGui::Checkbox("##vis", &obj.visible)) { - changed = true; - } + if (ImGui::Checkbox("##vis", &obj.visible)) { changed = true; } ImGui::PopStyleVar(); ImGui::SameLine(); @@ -299,12 +331,11 @@ static bool draw_object_node(scene_graph::Object &obj, bool is_selected = (state.selected_object == &obj); bool has_children = obj.children_count() > 0; - ImGuiTreeNodeFlags flags = - ImGuiTreeNodeFlags_OpenOnArrow | ImGuiTreeNodeFlags_OpenOnDoubleClick | ImGuiTreeNodeFlags_SpanAvailWidth; + ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_OpenOnArrow + | ImGuiTreeNodeFlags_OpenOnDoubleClick + | ImGuiTreeNodeFlags_SpanAvailWidth; - if (is_selected) { - flags |= ImGuiTreeNodeFlags_Selected; - } + if (is_selected) { flags |= ImGuiTreeNodeFlags_Selected; } if (!has_children) { flags |= ImGuiTreeNodeFlags_Leaf | ImGuiTreeNodeFlags_NoTreePushOnOpen; } @@ -322,13 +353,18 @@ static bool draw_object_node(scene_graph::Object &obj, ImGui::SameLine(); ImGui::SetNextItemWidth(-1); ImGui::SetKeyboardFocusHere(); - if (ImGui::InputText("##rename", state.rename_buf, sizeof(state.rename_buf), ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_AutoSelectAll)) { + if (ImGui::InputText("##rename", + state.rename_buf, + sizeof(state.rename_buf), + ImGuiInputTextFlags_EnterReturnsTrue + | ImGuiInputTextFlags_AutoSelectAll)) { obj.name = state.rename_buf; state.renaming_object = nullptr; changed = true; } // Cancel rename on Escape or click elsewhere - if (ImGui::IsKeyPressed(ImGuiKey_Escape) || (!ImGui::IsItemActive() && ImGui::IsMouseClicked(0))) { + if (ImGui::IsKeyPressed(ImGuiKey_Escape) + || (!ImGui::IsItemActive() && ImGui::IsMouseClicked(0))) { state.renaming_object = nullptr; } } else { @@ -337,22 +373,27 @@ static bool draw_object_node(scene_graph::Object &obj, bool is_active_cam = obj.find_feature() && (&obj == &scene.active_camera_object()); if (is_active_cam) { - std::snprintf(label, sizeof(label), "%s %s (active)", icon, obj.name.c_str()); + std::snprintf( + label, sizeof(label), "%s %s (active)", icon, obj.name.c_str()); } else { - std::snprintf(label, sizeof(label), "%s %s", icon, obj.name.c_str()); + std::snprintf( + label, sizeof(label), "%s %s", icon, obj.name.c_str()); } node_open = ImGui::TreeNodeEx(label, flags); // Click to select - if (ImGui::IsItemClicked(ImGuiMouseButton_Left) && !ImGui::IsItemToggledOpen()) { + if (ImGui::IsItemClicked(ImGuiMouseButton_Left) + && !ImGui::IsItemToggledOpen()) { state.selected_object = &obj; } } // ── Drag source (permanent objects cannot be dragged) ────────── - if (!obj.permanent && ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) { + if (!obj.permanent + && ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) { state.drag_source = &obj; - ImGui::SetDragDropPayload("SCENE_OBJECT", &state.drag_source, sizeof(scene_graph::Object *)); + ImGui::SetDragDropPayload( + "SCENE_OBJECT", &state.drag_source, sizeof(scene_graph::Object *)); ImGui::Text("%s %s", icon_for_object(obj), obj.name.c_str()); ImGui::EndDragDropSource(); } @@ -363,7 +404,8 @@ static bool draw_object_node(scene_graph::Object &obj, auto *source = *static_cast(payload->Data); // Prevent: dropping onto self, dropping onto own descendant, // or dropping onto current parent (no-op). - if (source && source != &obj && source->parent() != &obj && !is_ancestor_of(source, &obj)) { + if (source && source != &obj && source->parent() != &obj + && !is_ancestor_of(source, &obj)) { auto detached = source->detach(); if (detached) { obj.add_child(std::move(detached)); @@ -387,9 +429,7 @@ static bool draw_object_node(scene_graph::Object &obj, // Reparent under this node if not root if (&obj != &scene.root()) { auto detached = mesh_obj.detach(); - if (detached) { - obj.add_child(std::move(detached)); - } + if (detached) { obj.add_child(std::move(detached)); } } changed = true; } @@ -417,7 +457,9 @@ static bool draw_object_node(scene_graph::Object &obj, ImGui::Separator(); if (!obj.permanent && ImGui::MenuItem("Rename")) { state.renaming_object = &obj; - std::strncpy(state.rename_buf, obj.name.c_str(), sizeof(state.rename_buf) - 1); + std::strncpy(state.rename_buf, + obj.name.c_str(), + sizeof(state.rename_buf) - 1); state.rename_buf[sizeof(state.rename_buf) - 1] = '\0'; } if (!obj.permanent && ImGui::MenuItem("Duplicate")) { @@ -545,7 +587,8 @@ bool draw_scene_tree(MeshScene &scene, MeshPanelState &state) { ImGui::Dummy(ImGui::GetContentRegionAvail()); if (ImGui::BeginDragDropTarget()) { if (auto *payload = ImGui::AcceptDragDropPayload("SCENE_OBJECT")) { - auto *source = *static_cast(payload->Data); + auto *source = + *static_cast(payload->Data); if (source && source->parent() != &root) { auto detached = source->detach(); if (detached) { @@ -558,7 +601,8 @@ bool draw_scene_tree(MeshScene &scene, MeshPanelState &state) { } // Deselect if clicked in empty area - if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(0) && !ImGui::IsAnyItemHovered()) { + if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(0) + && !ImGui::IsAnyItemHovered()) { state.selected_object = nullptr; } } @@ -604,14 +648,15 @@ bool draw_property_panel(MeshScene & /*scene*/, MeshPanelState &state) { ImGui::Text("Triangles: %zu", mesh_data->triangle_count()); ImGui::Text("Edges: %zu", mesh_data->edge_count()); - ImGui::TextDisabled("Attributes:"); - if (mesh_data->has_normals()) { + // Show bound roles. + ImGui::TextDisabled("Roles:"); + if (mesh_data->has_positions()) { ImGui::SameLine(); - ImGui::Text("N"); + ImGui::Text("P"); } - if (mesh_data->has_vertex_colors()) { + if (mesh_data->has_normals()) { ImGui::SameLine(); - ImGui::Text("C"); + ImGui::Text("N"); } if (mesh_data->has_scalar_field()) { ImGui::SameLine(); @@ -631,21 +676,15 @@ bool draw_property_panel(MeshScene & /*scene*/, MeshPanelState &state) { // We need contiguous float[3] for DragFloat3, so copy into // local arrays, edit, then set back. - float tv[3] = { - static_cast(t(0)), - static_cast(t(1)), - static_cast(t(2)) - }; - float rv[3] = { - static_cast(euler(0)), - static_cast(euler(1)), - static_cast(euler(2)) - }; - float sv[3] = { - static_cast(s(0)), - static_cast(s(1)), - static_cast(s(2)) - }; + float tv[3] = {static_cast(t(0)), + static_cast(t(1)), + static_cast(t(2))}; + float rv[3] = {static_cast(euler(0)), + static_cast(euler(1)), + static_cast(euler(2))}; + float sv[3] = {static_cast(s(0)), + static_cast(s(1)), + static_cast(s(2))}; bool t_changed = false; t_changed |= ImGui::DragFloat3("Translation", tv, 0.01f); @@ -682,15 +721,166 @@ bool draw_property_panel(MeshScene & /*scene*/, MeshPanelState &state) { // ── Render State ──────────────────────────────────────────── if (mesh_data) { - if (ImGui::CollapsingHeader("Render State", ImGuiTreeNodeFlags_DefaultOpen)) { - changed |= draw_render_state_controls(mesh_data->render_state(), mesh_data->has_normals()); + if (ImGui::CollapsingHeader("Render State", + ImGuiTreeNodeFlags_DefaultOpen)) { + changed |= draw_render_state_controls(mesh_data->render_state(), + mesh_data->has_normals()); + } + + // ── Attribute Bindings ────────────────────────────────── + // + // Let the user select which mesh attribute is bound to each + // visualization role (positions, normals, scalar field). + if (ImGui::CollapsingHeader("Attribute Bindings")) { + const auto &discovered = mesh_data->discovered_attributes(); + + // Helper lambda: draw a combo that lets the user pick an + // attribute for a role. Returns true if the selection changed. + // filter_fn filters which discovered attributes are eligible. + auto draw_role_combo = + [&](const char *label, + const scene_graph::RoleBinding &binding, + auto assign_fn, + auto clear_fn, + auto filter_fn) -> bool { + bool role_changed = false; + + // Find current selection label. + const char *current_label = "(None)"; + int current_idx = -1; + for (int i = 0; i < static_cast(discovered.size()); + ++i) { + if (binding.is_bound() + && &binding.source.attribute() + == &discovered[i].handle.attribute()) { + current_label = discovered[i].name.c_str(); + current_idx = i; + break; + } + } + + if (ImGui::BeginCombo(label, current_label)) { + // "(None)" option to clear the role. + if (ImGui::Selectable("(None)", current_idx < 0)) { + clear_fn(); + role_changed = true; + } + for (int i = 0; i < static_cast(discovered.size()); + ++i) { + if (!filter_fn(discovered[i])) continue; + bool selected = (i == current_idx); + // Show name + type info in the combo item. + char item_label[256]; + std::snprintf( + item_label, + sizeof(item_label), + "%s (dim%d, %u comp, %zu elts)", + discovered[i].name.c_str(), + static_cast(discovered[i].dimension), + static_cast( + discovered[i].component_count), + discovered[i].count); + if (ImGui::Selectable(item_label, selected)) { + assign_fn(discovered[i].handle); + role_changed = true; + } + if (selected) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + return role_changed; + }; + + // Position role: any floating-point attribute with 1+ + // components. + changed |= draw_role_combo( + "Position##role", + mesh_data->position_binding(), + [&](auto h) { mesh_data->assign_position(h); }, + [&]() { mesh_data->clear_position(); }, + [](const scene_graph::DiscoveredAttribute &da) { + return da.is_floating_point && da.component_count >= 1; + }); + + // Normal role: any floating-point attribute with 2+ components. + changed |= draw_role_combo( + "Normal##role", + mesh_data->normal_binding(), + [&](auto h) { mesh_data->assign_normal(h); }, + [&]() { mesh_data->clear_normal(); }, + [](const scene_graph::DiscoveredAttribute &da) { + return da.is_floating_point && da.component_count >= 2; + }); + + // Scalar role: any attribute (will be converted to float). + changed |= draw_role_combo( + "Scalar##role", + mesh_data->scalar_binding(), + [&](auto h) { + mesh_data->assign_scalar(h, + mesh_data->scalar_component()); + }, + [&]() { mesh_data->clear_scalar(); }, + [](const scene_graph::DiscoveredAttribute &da) { + return da.component_count >= 1; + }); + + // Scalar component selector (only if scalar is bound to a + // multi-component attribute). + if (mesh_data->has_scalar_field()) { + // Find the source attribute's component count. + const auto &scl_bind = mesh_data->scalar_binding(); + uint8_t src_components = 0; + for (const auto &da : discovered) { + if (scl_bind.is_bound() + && &scl_bind.source.attribute() + == &da.handle.attribute()) { + src_components = da.component_count; + break; + } + } + if (src_components > 1) { + int comp = mesh_data->scalar_component(); + const char *comp_labels[] = { + "Magnitude", "X (0)", "Y (1)", "Z (2)", "W (3)"}; + int max_items = + std::min(static_cast(src_components) + 1, 5); + // Map: -1 -> 0 (Magnitude), 0 -> 1 (X), 1 -> 2 (Y), ... + int combo_idx = comp + 1; + if (combo_idx < 0 || combo_idx >= max_items) + combo_idx = 0; + if (ImGui::Combo("Component##scalar", + &combo_idx, + comp_labels, + max_items)) { + int new_comp = combo_idx - 1; + mesh_data->assign_scalar(scl_bind.source, new_comp); + changed = true; + } + } + } + + // Show all discovered attributes in a collapsible list. + if (!discovered.empty() && ImGui::TreeNode("All Attributes")) { + for (const auto &da : discovered) { + ImGui::BulletText( + "%s: dim=%d, %u comp, %zu elts%s", + da.name.c_str(), + static_cast(da.dimension), + static_cast(da.component_count), + da.count, + da.is_floating_point ? " [float]" : ""); + } + ImGui::TreePop(); + } } } // ── BVH Overlay ───────────────────────────────────────────── auto *bvh_data = obj->find_feature(); if (bvh_data) { - if (ImGui::CollapsingHeader("BVH Overlay", ImGuiTreeNodeFlags_DefaultOpen)) { + if (ImGui::CollapsingHeader("BVH Overlay", + ImGuiTreeNodeFlags_DefaultOpen)) { // Enabled toggle if (ImGui::Checkbox("Show Overlay", &bvh_data->enabled)) { bvh_data->mark_overlay_dirty(); @@ -721,11 +911,13 @@ bool draw_property_panel(MeshScene & /*scene*/, MeshPanelState &state) { "SAH (Best Quality)", "Object Median (Balanced)", "Spatial Median", - "LBVH (Fastest)" - }; + "LBVH (Fastest)"}; int strategy_idx = static_cast(bvh_data->strategy); - if (ImGui::Combo("##strategy", &strategy_idx, strategy_names, 4)) { - bvh_data->strategy = static_cast(strategy_idx); + if (ImGui::Combo( + "##strategy", &strategy_idx, strategy_names, 4)) { + bvh_data->strategy = + static_cast( + strategy_idx); bvh_data->mark_dirty(); changed = true; } @@ -733,7 +925,8 @@ bool draw_property_panel(MeshScene & /*scene*/, MeshPanelState &state) { // Max leaf size int leaf_size = static_cast(bvh_data->max_leaf_size); if (ImGui::SliderInt("Max Leaf Size", &leaf_size, 1, 32)) { - bvh_data->max_leaf_size = static_cast(leaf_size); + bvh_data->max_leaf_size = + static_cast(leaf_size); bvh_data->mark_dirty(); changed = true; } @@ -744,7 +937,10 @@ bool draw_property_panel(MeshScene & /*scene*/, MeshPanelState &state) { if (bvh_data->is_built()) { ImGui::Text("BVH height: %d", bvh_data->bvh_height()); int prev_depth = bvh_data->display_depth; - ImGui::SliderInt("Display Depth", &bvh_data->display_depth, 0, bvh_data->bvh_height()); + ImGui::SliderInt("Display Depth", + &bvh_data->display_depth, + 0, + bvh_data->bvh_height()); if (bvh_data->display_depth != prev_depth) { bvh_data->mark_overlay_dirty(); changed = true; @@ -784,11 +980,9 @@ bool draw_scene_lighting_panel(MeshScene &scene, MeshPanelState &state) { if (light.enabled) { // Direction in local space (camera space for headlight). - float dir[3] = { - static_cast(light.direction(0)), - static_cast(light.direction(1)), - static_cast(light.direction(2)) - }; + float dir[3] = {static_cast(light.direction(0)), + static_cast(light.direction(1)), + static_cast(light.direction(2))}; if (ImGui::DragFloat3("Direction", dir, 0.01f, -1.0f, 1.0f)) { light.direction(0) = dir[0]; light.direction(1) = dir[1]; @@ -799,11 +993,9 @@ bool draw_scene_lighting_panel(MeshScene &scene, MeshPanelState &state) { ImGui::TextDisabled("(camera-local)"); // Light color - float col[3] = { - static_cast(light.color(0)), - static_cast(light.color(1)), - static_cast(light.color(2)) - }; + float col[3] = {static_cast(light.color(0)), + static_cast(light.color(1)), + static_cast(light.color(2))}; if (ImGui::ColorEdit3("Light Color", col)) { light.color(0) = col[0]; light.color(1) = col[1]; @@ -835,4 +1027,4 @@ bool draw_mesh_controls(MeshScene &scene, MeshPanelState &state) { return changed; } -}// namespace balsa::visualization::vulkan::imgui +} // namespace balsa::visualization::vulkan::imgui diff --git a/visualization/src/vulkan/mesh_buffers.cpp b/visualization/src/vulkan/mesh_buffers.cpp index 5d9a2b5..48051cd 100644 --- a/visualization/src/vulkan/mesh_buffers.cpp +++ b/visualization/src/vulkan/mesh_buffers.cpp @@ -1,8 +1,8 @@ #include "balsa/visualization/vulkan/mesh_buffers.hpp" #include "balsa/visualization/vulkan/film.hpp" -#include #include +#include namespace balsa::visualization::vulkan { @@ -13,90 +13,157 @@ namespace { const void *data, vk::DeviceSize byte_size, vk::BufferUsageFlags usage) { - VulkanBuffer buf( - film, - byte_size, - usage | vk::BufferUsageFlagBits::eTransferDst, - vk::MemoryPropertyFlagBits::eDeviceLocal); + VulkanBuffer buf(film, + byte_size, + usage | vk::BufferUsageFlagBits::eTransferDst, + vk::MemoryPropertyFlagBits::eDeviceLocal); buf.upload_staged(film, data, byte_size); return buf; } -}// namespace +} // namespace // ── Upload: vertex attributes ──────────────────────────────────────── -void MeshBuffers::upload_positions(Film &film, std::span data, uint32_t vertex_count) { - if (data.size() < static_cast(vertex_count) * 3) { - throw std::runtime_error("MeshBuffers::upload_positions: data too small for vertex_count"); +void MeshBuffers::upload_positions(Film &film, + const void *data, + std::size_t byte_size, + uint32_t vertex_count, + uint8_t components) { + if (components < 1 || components > 4) { + throw std::runtime_error( + "MeshBuffers::upload_positions: components must be 1–4"); + } + std::size_t expected = + static_cast(vertex_count) * components * sizeof(float); + if (byte_size < expected) { + throw std::runtime_error( + "MeshBuffers::upload_positions: data too small for vertex_count × " + "components"); } _vertex_count = vertex_count; - _positions = make_device_buffer( - film, data.data(), static_cast(vertex_count) * 3 * sizeof(float), vk::BufferUsageFlagBits::eVertexBuffer); + _position_components = components; + _positions = make_device_buffer(film, + data, + static_cast(expected), + vk::BufferUsageFlagBits::eVertexBuffer); } -void MeshBuffers::upload_normals(Film &film, std::span data) { - if (data.size() < static_cast(_vertex_count) * 3) { - throw std::runtime_error("MeshBuffers::upload_normals: data too small for vertex_count (upload positions first)"); +void MeshBuffers::upload_normals(Film &film, + const void *data, + std::size_t byte_size, + uint8_t components) { + if (components < 1 || components > 4) { + throw std::runtime_error( + "MeshBuffers::upload_normals: components must be 1–4"); } - _normals = make_device_buffer( - film, data.data(), static_cast(_vertex_count) * 3 * sizeof(float), vk::BufferUsageFlagBits::eVertexBuffer); + std::size_t expected = + static_cast(_vertex_count) * components * sizeof(float); + if (byte_size < expected) { + throw std::runtime_error( + "MeshBuffers::upload_normals: data too small for vertex_count × " + "components (upload positions first)"); + } + _normal_components = components; + _normals = make_device_buffer(film, + data, + static_cast(expected), + vk::BufferUsageFlagBits::eVertexBuffer); } void MeshBuffers::upload_colors(Film &film, std::span data) { if (data.size() < static_cast(_vertex_count) * 4) { - throw std::runtime_error("MeshBuffers::upload_colors: data too small for vertex_count (upload positions first)"); + throw std::runtime_error( + "MeshBuffers::upload_colors: data too small for vertex_count " + "(upload positions first)"); } - _colors = make_device_buffer( - film, data.data(), static_cast(_vertex_count) * 4 * sizeof(float), vk::BufferUsageFlagBits::eVertexBuffer); + _colors = make_device_buffer(film, + data.data(), + static_cast(_vertex_count) * 4 + * sizeof(float), + vk::BufferUsageFlagBits::eVertexBuffer); } void MeshBuffers::upload_scalars(Film &film, std::span data) { if (data.size() < static_cast(_vertex_count)) { - throw std::runtime_error("MeshBuffers::upload_scalars: data too small for vertex_count (upload positions first)"); + throw std::runtime_error( + "MeshBuffers::upload_scalars: data too small for vertex_count " + "(upload positions first)"); } - _scalars = make_device_buffer( - film, data.data(), static_cast(_vertex_count) * sizeof(float), vk::BufferUsageFlagBits::eVertexBuffer); + _scalars = make_device_buffer(film, + data.data(), + static_cast(_vertex_count) + * sizeof(float), + vk::BufferUsageFlagBits::eVertexBuffer); } // ── Upload: index buffers ──────────────────────────────────────────── -void MeshBuffers::upload_triangle_indices(Film &film, std::span data, uint32_t triangle_count) { +void MeshBuffers::upload_triangle_indices(Film &film, + std::span data, + uint32_t triangle_count) { if (data.size() < static_cast(triangle_count) * 3) { - throw std::runtime_error("MeshBuffers::upload_triangle_indices: data too small for triangle_count"); + throw std::runtime_error( + "MeshBuffers::upload_triangle_indices: data too small for " + "triangle_count"); } _triangle_count = triangle_count; _triangle_indices = make_device_buffer( - film, data.data(), static_cast(triangle_count) * 3 * sizeof(uint32_t), vk::BufferUsageFlagBits::eIndexBuffer); + film, + data.data(), + static_cast(triangle_count) * 3 * sizeof(uint32_t), + vk::BufferUsageFlagBits::eIndexBuffer); } -void MeshBuffers::upload_edge_indices(Film &film, std::span data, uint32_t edge_count) { +void MeshBuffers::upload_edge_indices(Film &film, + std::span data, + uint32_t edge_count) { if (data.size() < static_cast(edge_count) * 2) { - throw std::runtime_error("MeshBuffers::upload_edge_indices: data too small for edge_count"); + throw std::runtime_error( + "MeshBuffers::upload_edge_indices: data too small for edge_count"); } _edge_count = edge_count; - _edge_indices = make_device_buffer( - film, data.data(), static_cast(edge_count) * 2 * sizeof(uint32_t), vk::BufferUsageFlagBits::eIndexBuffer); + _edge_indices = make_device_buffer(film, + data.data(), + static_cast(edge_count) + * 2 * sizeof(uint32_t), + vk::BufferUsageFlagBits::eIndexBuffer); } // ── Upload from size_t indices (narrowing conversion) ──────────────── -void MeshBuffers::upload_triangle_indices_from_sizet(Film &film, std::span data, uint32_t triangle_count) { +void MeshBuffers::upload_triangle_indices_from_sizet( + Film &film, + std::span data, + uint32_t triangle_count) { size_t count = static_cast(triangle_count) * 3; if (data.size() < count) { - throw std::runtime_error("MeshBuffers::upload_triangle_indices_from_sizet: data too small"); + throw std::runtime_error( + "MeshBuffers::upload_triangle_indices_from_sizet: data too small"); } std::vector converted(count); - std::transform(data.begin(), data.begin() + count, converted.begin(), [](std::size_t v) -> uint32_t { return static_cast(v); }); + std::transform( + data.begin(), + data.begin() + count, + converted.begin(), + [](std::size_t v) -> uint32_t { return static_cast(v); }); upload_triangle_indices(film, converted, triangle_count); } -void MeshBuffers::upload_edge_indices_from_sizet(Film &film, std::span data, uint32_t edge_count) { +void MeshBuffers::upload_edge_indices_from_sizet( + Film &film, + std::span data, + uint32_t edge_count) { size_t count = static_cast(edge_count) * 2; if (data.size() < count) { - throw std::runtime_error("MeshBuffers::upload_edge_indices_from_sizet: data too small"); + throw std::runtime_error( + "MeshBuffers::upload_edge_indices_from_sizet: data too small"); } std::vector converted(count); - std::transform(data.begin(), data.begin() + count, converted.begin(), [](std::size_t v) -> uint32_t { return static_cast(v); }); + std::transform( + data.begin(), + data.begin() + count, + converted.begin(), + [](std::size_t v) -> uint32_t { return static_cast(v); }); upload_edge_indices(film, converted, edge_count); } @@ -112,54 +179,85 @@ void MeshBuffers::release() { _vertex_count = 0; _triangle_count = 0; _edge_count = 0; + _position_components = 3; + _normal_components = 3; } // ── Vertex input descriptions ──────────────────────────────────────── -std::vector MeshBuffers::binding_descriptions() const { +namespace { + // Map component count (1–4) to the corresponding VkFormat for float + // attributes. + vk::Format float_format_for_components(uint8_t components) { + switch (components) { + case 1: + return vk::Format::eR32Sfloat; + case 2: + return vk::Format::eR32G32Sfloat; + case 3: + return vk::Format::eR32G32B32Sfloat; + case 4: + return vk::Format::eR32G32B32A32Sfloat; + default: + return vk::Format::eR32G32B32Sfloat; // fallback + } + } +} // namespace + +std::vector + MeshBuffers::binding_descriptions() const { std::vector bindings; - // binding 0: positions (always present if we have vertices) + // binding 0: positions (variable component count) if (has_positions()) { - bindings.push_back({ 0, sizeof(float) * 3, vk::VertexInputRate::eVertex }); + bindings.push_back( + {0, + static_cast(sizeof(float) * _position_components), + vk::VertexInputRate::eVertex}); } - // binding 1: normals + // binding 1: normals (variable component count) if (has_normals()) { - bindings.push_back({ 1, sizeof(float) * 3, vk::VertexInputRate::eVertex }); + bindings.push_back( + {1, + static_cast(sizeof(float) * _normal_components), + vk::VertexInputRate::eVertex}); } // binding 2: colors if (has_colors()) { - bindings.push_back({ 2, sizeof(float) * 4, vk::VertexInputRate::eVertex }); + bindings.push_back( + {2, sizeof(float) * 4, vk::VertexInputRate::eVertex}); } // binding 3: scalars if (has_scalars()) { - bindings.push_back({ 3, sizeof(float) * 1, vk::VertexInputRate::eVertex }); + bindings.push_back( + {3, sizeof(float) * 1, vk::VertexInputRate::eVertex}); } return bindings; } -std::vector MeshBuffers::attribute_descriptions() const { +std::vector + MeshBuffers::attribute_descriptions() const { std::vector attrs; - // location 0, binding 0: inPosition (vec3) + // location 0, binding 0: inPosition (vec2/vec3/vec4, auto-fills missing) if (has_positions()) { - attrs.push_back({ 0, 0, vk::Format::eR32G32B32Sfloat, 0 }); + attrs.push_back( + {0, 0, float_format_for_components(_position_components), 0}); } - // location 1, binding 1: inNormal (vec3) + // location 1, binding 1: inNormal (vec2/vec3/vec4, auto-fills missing) if (has_normals()) { - attrs.push_back({ 1, 1, vk::Format::eR32G32B32Sfloat, 0 }); + attrs.push_back( + {1, 1, float_format_for_components(_normal_components), 0}); } // location 2, binding 2: inColor (vec4) if (has_colors()) { - attrs.push_back({ 2, 2, vk::Format::eR32G32B32A32Sfloat, 0 }); + attrs.push_back({2, 2, vk::Format::eR32G32B32A32Sfloat, 0}); } // location 3, binding 3: inScalar (float) - if (has_scalars()) { - attrs.push_back({ 3, 3, vk::Format::eR32Sfloat, 0 }); - } + if (has_scalars()) { attrs.push_back({3, 3, vk::Format::eR32Sfloat, 0}); } return attrs; } -}// namespace balsa::visualization::vulkan +} // namespace balsa::visualization::vulkan diff --git a/visualization/src/vulkan/mesh_pipeline.cpp b/visualization/src/vulkan/mesh_pipeline.cpp index 5e495f7..b66775c 100644 --- a/visualization/src/vulkan/mesh_pipeline.cpp +++ b/visualization/src/vulkan/mesh_pipeline.cpp @@ -1,8 +1,8 @@ #include "balsa/visualization/vulkan/mesh_pipeline.hpp" +#include "balsa/scene_graph/embedding_traits.hpp" +#include "balsa/visualization/shaders/mesh_shader.hpp" #include "balsa/visualization/vulkan/film.hpp" #include "balsa/visualization/vulkan/mesh_buffers.hpp" -#include "balsa/visualization/shaders/mesh_shader.hpp" -#include "balsa/scene_graph/embedding_traits.hpp" #include #include @@ -16,16 +16,23 @@ static std::size_t hash_combine(std::size_t seed, std::size_t v) { return seed; } -std::size_t MeshPipelineKeyHash::operator()(const MeshPipelineKey &k) const noexcept { +std::size_t + MeshPipelineKeyHash::operator()(const MeshPipelineKey &k) const noexcept { std::size_t h = std::hash{}(static_cast(k.shading)); - h = hash_combine(h, std::hash{}(static_cast(k.color_source))); - h = hash_combine(h, std::hash{}(static_cast(k.normal_source))); - h = hash_combine(h, std::hash{}(static_cast(k.topology))); + h = hash_combine( + h, std::hash{}(static_cast(k.color_source))); + h = hash_combine( + h, std::hash{}(static_cast(k.normal_source))); + h = hash_combine(h, + std::hash{}(static_cast(k.topology))); h = hash_combine(h, std::hash{}(k.colormap_name)); h = hash_combine(h, std::hash{}(k.has_normals)); h = hash_combine(h, std::hash{}(k.has_colors)); h = hash_combine(h, std::hash{}(k.has_scalars)); - h = hash_combine(h, std::hash{}(static_cast(k.cull_mode))); + h = hash_combine(h, std::hash{}(k.position_components)); + h = hash_combine(h, std::hash{}(k.normal_components)); + h = hash_combine(h, + std::hash{}(static_cast(k.cull_mode))); h = hash_combine(h, std::hash{}(k.wireframe_overlay)); h = hash_combine(h, std::hash{}(k.msaa_samples)); h = hash_combine(h, std::hash{}(k.render_pass)); @@ -48,12 +55,14 @@ MeshPipelineKey make_pipeline_key(const MeshRenderState &state, // Normalise colormap name: only relevant for ScalarField key.colormap_name = (state.color_source == ColorSource::ScalarField) - ? state.colormap_name - : std::string{}; + ? state.colormap_name + : std::string{}; key.has_normals = buffers.has_normals(); key.has_colors = buffers.has_colors(); key.has_scalars = buffers.has_scalars(); + key.position_components = buffers.position_components(); + key.normal_components = buffers.normal_components(); // ── Safety net: enforce normal constraints ────────────────────── // The UI and load-time code should already enforce these via @@ -63,7 +72,8 @@ MeshPipelineKey make_pipeline_key(const MeshRenderState &state, key.normal_source = NormalSource::ComputedInShader; } if (key.normal_source == NormalSource::ComputedInShader - && (key.shading == ShadingModel::Phong || key.shading == ShadingModel::Gouraud)) { + && (key.shading == ShadingModel::Phong + || key.shading == ShadingModel::Gouraud)) { key.shading = ShadingModel::Flat; } @@ -71,7 +81,8 @@ MeshPipelineKey make_pipeline_key(const MeshRenderState &state, key.wireframe_overlay = wireframe_overlay; key.msaa_samples = static_cast(film.sample_count()); - key.render_pass = reinterpret_cast(static_cast(film.default_render_pass())); + key.render_pass = reinterpret_cast( + static_cast(film.default_render_pass())); key.depth_test = film.has_depth_stencil(); return key; @@ -79,18 +90,13 @@ MeshPipelineKey make_pipeline_key(const MeshRenderState &state, // ── MeshPipelineManager lifecycle ──────────────────────────────────── -MeshPipelineManager::~MeshPipelineManager() { - release(); -} +MeshPipelineManager::~MeshPipelineManager() { release(); } MeshPipelineManager::MeshPipelineManager(MeshPipelineManager &&o) noexcept - : _device(o._device), - _film(o._film), + : _device(o._device), _film(o._film), _descriptor_set_layout(o._descriptor_set_layout), - _pipeline_layout(o._pipeline_layout), - _descriptor_pool(o._descriptor_pool), - _cache(std::move(o._cache)), - _initialized(o._initialized) { + _pipeline_layout(o._pipeline_layout), _descriptor_pool(o._descriptor_pool), + _cache(std::move(o._cache)), _initialized(o._initialized) { o._device = vk::Device{}; o._film = nullptr; o._descriptor_set_layout = vk::DescriptorSetLayout{}; @@ -99,7 +105,8 @@ MeshPipelineManager::MeshPipelineManager(MeshPipelineManager &&o) noexcept o._initialized = false; } -MeshPipelineManager &MeshPipelineManager::operator=(MeshPipelineManager &&o) noexcept { +MeshPipelineManager & + MeshPipelineManager::operator=(MeshPipelineManager &&o) noexcept { if (this != &o) { release(); _device = o._device; @@ -120,9 +127,7 @@ MeshPipelineManager &MeshPipelineManager::operator=(MeshPipelineManager &&o) noe } void MeshPipelineManager::init(Film &film, uint32_t max_descriptor_sets) { - if (_initialized) { - release(); - } + if (_initialized) { release(); } _film = &film; _device = film.device(); @@ -135,17 +140,13 @@ void MeshPipelineManager::init(Film &film, uint32_t max_descriptor_sets) { } void MeshPipelineManager::release() { - if (!_initialized) { - return; - } + if (!_initialized) { return; } if (_device) { _device.waitIdle(); // Destroy cached pipelines for (auto &[key, pipe] : _cache) { - if (pipe) { - _device.destroyPipeline(pipe); - } + if (pipe) { _device.destroyPipeline(pipe); } } _cache.clear(); @@ -172,9 +173,7 @@ void MeshPipelineManager::invalidate_pipelines() { if (_device) { _device.waitIdle(); for (auto &[key, pipe] : _cache) { - if (pipe) { - _device.destroyPipeline(pipe); - } + if (pipe) { _device.destroyPipeline(pipe); } } } _cache.clear(); @@ -193,13 +192,16 @@ void MeshPipelineManager::create_descriptor_set_layout() { transform_binding.setBinding(0); transform_binding.setDescriptorType(vk::DescriptorType::eUniformBuffer); transform_binding.setDescriptorCount(1); - transform_binding.setStageFlags(vk::ShaderStageFlagBits::eVertex | vk::ShaderStageFlagBits::eFragment); + transform_binding.setStageFlags(vk::ShaderStageFlagBits::eVertex + | vk::ShaderStageFlagBits::eFragment); auto &material_binding = bindings[1]; material_binding.setBinding(1); - material_binding.setDescriptorType(vk::DescriptorType::eUniformBufferDynamic); + material_binding.setDescriptorType( + vk::DescriptorType::eUniformBufferDynamic); material_binding.setDescriptorCount(1); - material_binding.setStageFlags(vk::ShaderStageFlagBits::eVertex | vk::ShaderStageFlagBits::eFragment); + material_binding.setStageFlags(vk::ShaderStageFlagBits::eVertex + | vk::ShaderStageFlagBits::eFragment); vk::DescriptorSetLayoutCreateInfo ci; ci.setBindings(bindings); @@ -218,9 +220,9 @@ void MeshPipelineManager::create_descriptor_pool(uint32_t max_sets) { // 1 dynamic uniform buffer (MaterialUBO). std::array pool_sizes; pool_sizes[0].setType(vk::DescriptorType::eUniformBuffer); - pool_sizes[0].setDescriptorCount(max_sets);// 1 per set + pool_sizes[0].setDescriptorCount(max_sets); // 1 per set pool_sizes[1].setType(vk::DescriptorType::eUniformBufferDynamic); - pool_sizes[1].setDescriptorCount(max_sets);// 1 per set + pool_sizes[1].setDescriptorCount(max_sets); // 1 per set vk::DescriptorPoolCreateInfo ci; ci.setFlags(vk::DescriptorPoolCreateFlagBits::eFreeDescriptorSet); @@ -283,7 +285,7 @@ void MeshPipelineManager::write_descriptor_set(vk::DescriptorSet ds, void MeshPipelineManager::free_descriptor_set(vk::DescriptorSet ds) { if (!_initialized || !ds) return; - _device.freeDescriptorSets(_descriptor_pool, { ds }); + _device.freeDescriptorSets(_descriptor_pool, {ds}); } // ── Pipeline access ────────────────────────────────────────────────── @@ -296,27 +298,27 @@ vk::Pipeline MeshPipelineManager::get_or_create(const MeshRenderState &state, if (!_initialized) { throw std::runtime_error("MeshPipelineManager: not initialized"); } - auto key = make_pipeline_key(state, topology, buffers, film, wireframe_overlay); + auto key = + make_pipeline_key(state, topology, buffers, film, wireframe_overlay); auto it = _cache.find(key); - if (it != _cache.end()) { - return it->second; - } + if (it != _cache.end()) { return it->second; } auto pipeline = create_pipeline(key); - if (pipeline) { - _cache[key] = pipeline; - } + if (pipeline) { _cache[key] = pipeline; } return pipeline; } // ── Pipeline creation ──────────────────────────────────────────────── vk::Pipeline MeshPipelineManager::create_pipeline(const MeshPipelineKey &key) { - spdlog::info("MeshPipelineManager: creating pipeline for shading={}, color={}, topology={}", - static_cast(key.shading), - static_cast(key.color_source), - static_cast(key.topology)); - - // Build a temporary MeshRenderState from the key to drive shader compilation + spdlog::info( + "MeshPipelineManager: creating pipeline for shading={}, color={}, " + "topology={}", + static_cast(key.shading), + static_cast(key.color_source), + static_cast(key.topology)); + + // Build a temporary MeshRenderState from the key to drive shader + // compilation MeshRenderState temp_state; temp_state.shading = key.shading; temp_state.color_source = key.color_source; @@ -324,19 +326,21 @@ vk::Pipeline MeshPipelineManager::create_pipeline(const MeshPipelineKey &key) { temp_state.colormap_name = key.colormap_name; using ET3F = scene_graph::embedding_traits3F; - shaders::MeshShader shader(temp_state, key.topology, key.wireframe_overlay); + shaders::MeshShader shader( + temp_state, key.topology, key.wireframe_overlay); auto vert_spv = shader.vert_spirv(); auto frag_spv = shader.frag_spirv(); if (vert_spv.empty() || frag_spv.empty()) { spdlog::error( - "MeshPipelineManager: shader compilation failed (empty SPIR-V), " - "skipping pipeline creation"); + "MeshPipelineManager: shader compilation failed (empty SPIR-V), " + "skipping pipeline creation"); return vk::Pipeline{}; } // Create shader modules - auto create_shader_module = [&](const std::vector &spv) -> vk::ShaderModule { + auto create_shader_module = + [&](const std::vector &spv) -> vk::ShaderModule { vk::ShaderModuleCreateInfo ci; ci.setCodeSize(sizeof(uint32_t) * spv.size()); ci.setPCode(spv.data()); @@ -363,31 +367,58 @@ vk::Pipeline MeshPipelineManager::create_pipeline(const MeshPipelineKey &key) { // ── Vertex input ──────────────────────────────────────────────── // - // Build binding/attribute descriptions from the key's attribute flags, - // matching the layout in mesh.vert: - // binding 0: vec3 position (location 0) - // binding 1: vec3 normal (location 1) — if has_normals - // binding 2: vec4 color (location 2) — if has_colors - // binding 3: float scalar (location 3) — if has_scalars + // Build binding/attribute descriptions from the key's attribute flags + // and component counts, matching the layout in mesh.vert: + // binding 0: position (vec2/vec3, location 0) — format from + // key.position_components binding 1: normal (vec2/vec3, location 1) + // — format from key.normal_components, if has_normals binding 2: color + // (vec4, location 2) — if has_colors binding 3: scalar (float, + // location 3) — if has_scalars + // + // Vulkan auto-fills missing components in the shader: a R32G32_SFLOAT + // attribute read as vec3 gets (x, y, 0.0). + + auto float_format = [](uint8_t components) -> vk::Format { + switch (components) { + case 1: + return vk::Format::eR32Sfloat; + case 2: + return vk::Format::eR32G32Sfloat; + case 3: + return vk::Format::eR32G32B32Sfloat; + case 4: + return vk::Format::eR32G32B32A32Sfloat; + default: + return vk::Format::eR32G32B32Sfloat; + } + }; std::vector binding_descs; std::vector attrib_descs; // Positions always present - binding_descs.push_back({ 0, sizeof(float) * 3, vk::VertexInputRate::eVertex }); - attrib_descs.push_back({ 0, 0, vk::Format::eR32G32B32Sfloat, 0 }); + binding_descs.push_back( + {0, + static_cast(sizeof(float) * key.position_components), + vk::VertexInputRate::eVertex}); + attrib_descs.push_back({0, 0, float_format(key.position_components), 0}); if (key.has_normals) { - binding_descs.push_back({ 1, sizeof(float) * 3, vk::VertexInputRate::eVertex }); - attrib_descs.push_back({ 1, 1, vk::Format::eR32G32B32Sfloat, 0 }); + binding_descs.push_back( + {1, + static_cast(sizeof(float) * key.normal_components), + vk::VertexInputRate::eVertex}); + attrib_descs.push_back({1, 1, float_format(key.normal_components), 0}); } if (key.has_colors) { - binding_descs.push_back({ 2, sizeof(float) * 4, vk::VertexInputRate::eVertex }); - attrib_descs.push_back({ 2, 2, vk::Format::eR32G32B32A32Sfloat, 0 }); + binding_descs.push_back( + {2, sizeof(float) * 4, vk::VertexInputRate::eVertex}); + attrib_descs.push_back({2, 2, vk::Format::eR32G32B32A32Sfloat, 0}); } if (key.has_scalars) { - binding_descs.push_back({ 3, sizeof(float), vk::VertexInputRate::eVertex }); - attrib_descs.push_back({ 3, 3, vk::Format::eR32Sfloat, 0 }); + binding_descs.push_back( + {3, sizeof(float), vk::VertexInputRate::eVertex}); + attrib_descs.push_back({3, 3, vk::Format::eR32Sfloat, 0}); } vk::PipelineVertexInputStateCreateInfo vertex_input; @@ -425,14 +456,15 @@ vk::Pipeline MeshPipelineManager::create_pipeline(const MeshPipelineKey &key) { } rasterization.setFrontFace(vk::FrontFace::eCounterClockwise); rasterization.setDepthBiasEnable( - key.topology == vk::PrimitiveTopology::eTriangleList ? VK_TRUE : VK_FALSE); + key.topology == vk::PrimitiveTopology::eTriangleList ? VK_TRUE + : VK_FALSE); rasterization.setLineWidth(1.0f); // ── Multisample ───────────────────────────────────────────────── vk::PipelineMultisampleStateCreateInfo multisampling; multisampling.setRasterizationSamples( - static_cast(key.msaa_samples)); + static_cast(key.msaa_samples)); multisampling.setSampleShadingEnable(VK_FALSE); multisampling.setMinSampleShading(1.0f); @@ -449,10 +481,12 @@ vk::Pipeline MeshPipelineManager::create_pipeline(const MeshPipelineKey &key) { vk::PipelineColorBlendAttachmentState color_blend_attachment; color_blend_attachment.setColorWriteMask( - vk::ColorComponentFlagBits::eR | vk::ColorComponentFlagBits::eG | vk::ColorComponentFlagBits::eB | vk::ColorComponentFlagBits::eA); + vk::ColorComponentFlagBits::eR | vk::ColorComponentFlagBits::eG + | vk::ColorComponentFlagBits::eB | vk::ColorComponentFlagBits::eA); color_blend_attachment.setBlendEnable(VK_TRUE); color_blend_attachment.setSrcColorBlendFactor(vk::BlendFactor::eSrcAlpha); - color_blend_attachment.setDstColorBlendFactor(vk::BlendFactor::eOneMinusSrcAlpha); + color_blend_attachment.setDstColorBlendFactor( + vk::BlendFactor::eOneMinusSrcAlpha); color_blend_attachment.setColorBlendOp(vk::BlendOp::eAdd); color_blend_attachment.setSrcAlphaBlendFactor(vk::BlendFactor::eOne); color_blend_attachment.setDstAlphaBlendFactor(vk::BlendFactor::eZero); @@ -476,7 +510,7 @@ vk::Pipeline MeshPipelineManager::create_pipeline(const MeshPipelineKey &key) { // ── Assemble ──────────────────────────────────────────────────── - vk::RenderPass rp{ reinterpret_cast(key.render_pass) }; + vk::RenderPass rp{reinterpret_cast(key.render_pass)}; vk::GraphicsPipelineCreateInfo pipeline_info; pipeline_info.setStageCount(2); @@ -500,7 +534,8 @@ vk::Pipeline MeshPipelineManager::create_pipeline(const MeshPipelineKey &key) { result = res.value; spdlog::info("MeshPipelineManager: pipeline created successfully"); } else { - spdlog::error("MeshPipelineManager: failed to create graphics pipeline"); + spdlog::error( + "MeshPipelineManager: failed to create graphics pipeline"); } // Cleanup shader modules @@ -510,4 +545,4 @@ vk::Pipeline MeshPipelineManager::create_pipeline(const MeshPipelineKey &key) { return result; } -}// namespace balsa::visualization::vulkan +} // namespace balsa::visualization::vulkan diff --git a/visualization/src/vulkan/native_film.cpp b/visualization/src/vulkan/native_film.cpp index af75750..7866a01 100644 --- a/visualization/src/vulkan/native_film.cpp +++ b/visualization/src/vulkan/native_film.cpp @@ -11,13 +11,13 @@ namespace { -static VKAPI_ATTR vk::Bool32 VKAPI_CALL - debugCallback(vk::DebugUtilsMessageSeverityFlagBitsEXT messageSeverity, - vk::DebugUtilsMessageTypeFlagsEXT messageType, - const vk::DebugUtilsMessengerCallbackDataEXT *pCallbackData, +static VKAPI_ATTR VkBool32 VKAPI_CALL + debugCallback(VkDebugUtilsMessageSeverityFlagBitsEXT messageSeverity, + VkDebugUtilsMessageTypeFlagsEXT messageType, + const VkDebugUtilsMessengerCallbackDataEXT *pCallbackData, void * /*pUserData*/) { spdlog::level::level_enum slevel = spdlog::level::debug; - switch (messageSeverity) { + switch (static_cast(messageSeverity)) { case vk::DebugUtilsMessageSeverityFlagBitsEXT::eVerbose: slevel = spdlog::level::debug; break; @@ -38,18 +38,19 @@ static VKAPI_ATTR vk::Bool32 VKAPI_CALL logger = spdlog::default_logger(); } + auto typeFlags = static_cast(messageType); std::string_view type; - if (messageType & vk::DebugUtilsMessageTypeFlagBitsEXT::eValidation) { + if (typeFlags & vk::DebugUtilsMessageTypeFlagBitsEXT::eValidation) { type = "Validation"; - } else if (messageType & vk::DebugUtilsMessageTypeFlagBitsEXT::ePerformance) { + } else if (typeFlags & vk::DebugUtilsMessageTypeFlagBitsEXT::ePerformance) { type = "Performance"; - } else if (messageType & vk::DebugUtilsMessageTypeFlagBitsEXT::eDeviceAddressBinding) { + } else if (typeFlags & vk::DebugUtilsMessageTypeFlagBitsEXT::eDeviceAddressBinding) { type = "DeviceAddressBinding"; } else { type = "General"; } logger->log(slevel, "Vk{}: {}", type, pCallbackData->pMessage); - return vk::False; + return VK_FALSE; } }// namespace diff --git a/visualization/src/vulkan/texture.cpp b/visualization/src/vulkan/texture.cpp new file mode 100644 index 0000000..6e30ce2 --- /dev/null +++ b/visualization/src/vulkan/texture.cpp @@ -0,0 +1,403 @@ +#include "balsa/visualization/vulkan/texture.hpp" +#include "balsa/visualization/vulkan/film.hpp" + +#include +#include +#include + +namespace balsa::visualization::vulkan { + +// ── Helpers ───────────────────────────────────────────────────────── + +auto VulkanTexture::vk_format() const -> vk::Format { + switch (_format) { + case Format::RGBA8: + return vk::Format::eR8G8B8A8Unorm; + case Format::RGBAF32: + return vk::Format::eR32G32B32A32Sfloat; + } + return vk::Format::eR8G8B8A8Unorm; +} + +auto VulkanTexture::bytes_per_pixel() const -> size_t { + switch (_format) { + case Format::RGBA8: + return 4; + case Format::RGBAF32: + return 16; + } + return 4; +} + +// ── Lifecycle ─────────────────────────────────────────────────────── + +VulkanTexture::~VulkanTexture() { release(); } + +VulkanTexture::VulkanTexture(VulkanTexture &&o) noexcept + : _device(o._device), _film(o._film), _image(o._image), _memory(o._memory), + _image_view(o._image_view), _sampler(o._sampler), + _staging_buffer(o._staging_buffer), _staging_memory(o._staging_memory), + _staging_size(o._staging_size), _width(o._width), _height(o._height), + _format(o._format) { + o._device = vk::Device{}; + o._film = nullptr; + o._image = vk::Image{}; + o._memory = vk::DeviceMemory{}; + o._image_view = vk::ImageView{}; + o._sampler = vk::Sampler{}; + o._staging_buffer = vk::Buffer{}; + o._staging_memory = vk::DeviceMemory{}; + o._staging_size = 0; + o._width = 0; + o._height = 0; +} + +auto VulkanTexture::operator=(VulkanTexture &&o) noexcept -> VulkanTexture & { + if (this != &o) { + release(); + _device = o._device; + _film = o._film; + _image = o._image; + _memory = o._memory; + _image_view = o._image_view; + _sampler = o._sampler; + _staging_buffer = o._staging_buffer; + _staging_memory = o._staging_memory; + _staging_size = o._staging_size; + _width = o._width; + _height = o._height; + _format = o._format; + o._device = vk::Device{}; + o._film = nullptr; + o._image = vk::Image{}; + o._memory = vk::DeviceMemory{}; + o._image_view = vk::ImageView{}; + o._sampler = vk::Sampler{}; + o._staging_buffer = vk::Buffer{}; + o._staging_memory = vk::DeviceMemory{}; + o._staging_size = 0; + o._width = 0; + o._height = 0; + } + return *this; +} + +auto VulkanTexture::release() -> void { + if (!_device) return; + + if (_sampler) { + _device.destroySampler(_sampler); + _sampler = vk::Sampler{}; + } + if (_image_view) { + _device.destroyImageView(_image_view); + _image_view = vk::ImageView{}; + } + if (_image) { + _device.destroyImage(_image); + _image = vk::Image{}; + } + if (_memory) { + _device.freeMemory(_memory); + _memory = vk::DeviceMemory{}; + } + if (_staging_buffer) { + _device.destroyBuffer(_staging_buffer); + _staging_buffer = vk::Buffer{}; + } + if (_staging_memory) { + _device.freeMemory(_staging_memory); + _staging_memory = vk::DeviceMemory{}; + } + + _staging_size = 0; + _width = 0; + _height = 0; +} + +// ── Create ────────────────────────────────────────────────────────── + +auto VulkanTexture::create(Film &film, + uint32_t width, + uint32_t height, + Format format) -> void { + release(); + + _device = film.device(); + _film = &film; + _width = width; + _height = height; + _format = format; + + // 1. Create the VkImage (device-local, transfer-dst + sampled). + vk::ImageCreateInfo image_ci; + image_ci.setImageType(vk::ImageType::e2D); + image_ci.setFormat(vk_format()); + image_ci.setExtent(vk::Extent3D{width, height, 1}); + image_ci.setMipLevels(1); + image_ci.setArrayLayers(1); + image_ci.setSamples(vk::SampleCountFlagBits::e1); + image_ci.setTiling(vk::ImageTiling::eOptimal); + image_ci.setUsage(vk::ImageUsageFlagBits::eTransferDst + | vk::ImageUsageFlagBits::eSampled); + image_ci.setSharingMode(vk::SharingMode::eExclusive); + image_ci.setInitialLayout(vk::ImageLayout::eUndefined); + + _image = _device.createImage(image_ci); + + // 2. Allocate device-local memory and bind. + auto mem_reqs = _device.getImageMemoryRequirements(_image); + + vk::MemoryAllocateInfo alloc_info; + alloc_info.setAllocationSize(mem_reqs.size); + alloc_info.setMemoryTypeIndex(film.find_memory_type( + mem_reqs.memoryTypeBits, vk::MemoryPropertyFlagBits::eDeviceLocal)); + + _memory = _device.allocateMemory(alloc_info); + _device.bindImageMemory(_image, _memory, 0); + + // 3. Create image view. + vk::ImageViewCreateInfo view_ci; + view_ci.setImage(_image); + view_ci.setViewType(vk::ImageViewType::e2D); + view_ci.setFormat(vk_format()); + view_ci.subresourceRange.setAspectMask(vk::ImageAspectFlagBits::eColor); + view_ci.subresourceRange.setBaseMipLevel(0); + view_ci.subresourceRange.setLevelCount(1); + view_ci.subresourceRange.setBaseArrayLayer(0); + view_ci.subresourceRange.setLayerCount(1); + + _image_view = _device.createImageView(view_ci); + + // 4. Create sampler (linear filtering, clamp to edge). + vk::SamplerCreateInfo sampler_ci; + sampler_ci.setMagFilter(vk::Filter::eLinear); + sampler_ci.setMinFilter(vk::Filter::eLinear); + sampler_ci.setAddressModeU(vk::SamplerAddressMode::eClampToEdge); + sampler_ci.setAddressModeV(vk::SamplerAddressMode::eClampToEdge); + sampler_ci.setAddressModeW(vk::SamplerAddressMode::eClampToEdge); + sampler_ci.setAnisotropyEnable(VK_FALSE); + sampler_ci.setMaxAnisotropy(1.0f); + sampler_ci.setBorderColor(vk::BorderColor::eIntOpaqueBlack); + sampler_ci.setUnnormalizedCoordinates(VK_FALSE); + sampler_ci.setCompareEnable(VK_FALSE); + sampler_ci.setMipmapMode(vk::SamplerMipmapMode::eLinear); + sampler_ci.setMipLodBias(0.0f); + sampler_ci.setMinLod(0.0f); + sampler_ci.setMaxLod(0.0f); + + _sampler = _device.createSampler(sampler_ci); + + // 5. Persistent staging buffer (host-visible + host-coherent). + _staging_size = + static_cast(width) * height * bytes_per_pixel(); + + vk::BufferCreateInfo staging_ci; + staging_ci.setSize(_staging_size); + staging_ci.setUsage(vk::BufferUsageFlagBits::eTransferSrc); + staging_ci.setSharingMode(vk::SharingMode::eExclusive); + + _staging_buffer = _device.createBuffer(staging_ci); + + auto staging_reqs = _device.getBufferMemoryRequirements(_staging_buffer); + + vk::MemoryAllocateInfo staging_alloc; + staging_alloc.setAllocationSize(staging_reqs.size); + staging_alloc.setMemoryTypeIndex( + film.find_memory_type(staging_reqs.memoryTypeBits, + vk::MemoryPropertyFlagBits::eHostVisible + | vk::MemoryPropertyFlagBits::eHostCoherent)); + + _staging_memory = _device.allocateMemory(staging_alloc); + _device.bindBufferMemory(_staging_buffer, _staging_memory, 0); + + spdlog::info("VulkanTexture: created {}x{} ({} bpp)", + width, + height, + bytes_per_pixel()); +} + +// ── Layout transition ─────────────────────────────────────────────── + +auto VulkanTexture::transition_layout(vk::CommandBuffer cmd, + vk::ImageLayout old_layout, + vk::ImageLayout new_layout) -> void { + vk::ImageMemoryBarrier barrier; + barrier.setOldLayout(old_layout); + barrier.setNewLayout(new_layout); + barrier.setSrcQueueFamilyIndex(VK_QUEUE_FAMILY_IGNORED); + barrier.setDstQueueFamilyIndex(VK_QUEUE_FAMILY_IGNORED); + barrier.setImage(_image); + barrier.subresourceRange.setAspectMask(vk::ImageAspectFlagBits::eColor); + barrier.subresourceRange.setBaseMipLevel(0); + barrier.subresourceRange.setLevelCount(1); + barrier.subresourceRange.setBaseArrayLayer(0); + barrier.subresourceRange.setLayerCount(1); + + vk::PipelineStageFlags src_stage; + vk::PipelineStageFlags dst_stage; + + if (old_layout == vk::ImageLayout::eUndefined + && new_layout == vk::ImageLayout::eTransferDstOptimal) { + barrier.setSrcAccessMask(vk::AccessFlags{}); + barrier.setDstAccessMask(vk::AccessFlagBits::eTransferWrite); + src_stage = vk::PipelineStageFlagBits::eTopOfPipe; + dst_stage = vk::PipelineStageFlagBits::eTransfer; + } else if (old_layout == vk::ImageLayout::eTransferDstOptimal + && new_layout == vk::ImageLayout::eShaderReadOnlyOptimal) { + barrier.setSrcAccessMask(vk::AccessFlagBits::eTransferWrite); + barrier.setDstAccessMask(vk::AccessFlagBits::eShaderRead); + src_stage = vk::PipelineStageFlagBits::eTransfer; + dst_stage = vk::PipelineStageFlagBits::eFragmentShader; + } else if (old_layout == vk::ImageLayout::eShaderReadOnlyOptimal + && new_layout == vk::ImageLayout::eTransferDstOptimal) { + barrier.setSrcAccessMask(vk::AccessFlagBits::eShaderRead); + barrier.setDstAccessMask(vk::AccessFlagBits::eTransferWrite); + src_stage = vk::PipelineStageFlagBits::eFragmentShader; + dst_stage = vk::PipelineStageFlagBits::eTransfer; + } else { + throw std::runtime_error( + "VulkanTexture: unsupported layout transition"); + } + + cmd.pipelineBarrier(src_stage, dst_stage, {}, {}, {}, {barrier}); +} + +// ── One-shot command buffer ───────────────────────────────────────── + +auto VulkanTexture::one_shot_command( + Film &film, + std::function record_fn) -> void { + vk::CommandBufferAllocateInfo cmd_alloc; + cmd_alloc.setCommandPool(film.graphics_command_pool()); + cmd_alloc.setLevel(vk::CommandBufferLevel::ePrimary); + cmd_alloc.setCommandBufferCount(1); + + auto cmd_buffers = _device.allocateCommandBuffers(cmd_alloc); + vk::CommandBuffer cmd = cmd_buffers[0]; + + vk::CommandBufferBeginInfo begin_info; + begin_info.setFlags(vk::CommandBufferUsageFlagBits::eOneTimeSubmit); + cmd.begin(begin_info); + + record_fn(cmd); + + cmd.end(); + + vk::SubmitInfo submit_info; + submit_info.setCommandBufferCount(1); + submit_info.setPCommandBuffers(&cmd); + + vk::Queue queue = film.graphics_queue(); + queue.submit(submit_info); + queue.waitIdle(); + + _device.freeCommandBuffers(film.graphics_command_pool(), cmd); +} + +// ── Full upload ───────────────────────────────────────────────────── + +auto VulkanTexture::upload(Film &film, const void *pixels, size_t byte_count) -> void { + if (!is_valid()) { + throw std::runtime_error("VulkanTexture::upload: texture not created"); + } + + size_t expected = static_cast(_width) * _height * bytes_per_pixel(); + if (byte_count < expected) { + throw std::runtime_error("VulkanTexture::upload: insufficient data"); + } + + // Copy into staging buffer. + void *mapped = _device.mapMemory(_staging_memory, 0, _staging_size); + std::memcpy(mapped, pixels, expected); + _device.unmapMemory(_staging_memory); + + // Record one-shot commands: transition, copy, transition. + one_shot_command(film, [&](vk::CommandBuffer cmd) { + // UNDEFINED -> TRANSFER_DST + transition_layout(cmd, + vk::ImageLayout::eUndefined, + vk::ImageLayout::eTransferDstOptimal); + + // Copy staging buffer -> image. + vk::BufferImageCopy region; + region.setBufferOffset(0); + region.setBufferRowLength(0); // tightly packed + region.setBufferImageHeight(0); // tightly packed + region.imageSubresource.setAspectMask(vk::ImageAspectFlagBits::eColor); + region.imageSubresource.setMipLevel(0); + region.imageSubresource.setBaseArrayLayer(0); + region.imageSubresource.setLayerCount(1); + region.setImageOffset({0, 0, 0}); + region.setImageExtent({_width, _height, 1}); + + cmd.copyBufferToImage(_staging_buffer, + _image, + vk::ImageLayout::eTransferDstOptimal, + {region}); + + // TRANSFER_DST -> SHADER_READ_ONLY + transition_layout(cmd, + vk::ImageLayout::eTransferDstOptimal, + vk::ImageLayout::eShaderReadOnlyOptimal); + }); +} + +// ── Partial region update ─────────────────────────────────────────── + +auto VulkanTexture::update_region(Film &film, + uint32_t x, + uint32_t y, + uint32_t w, + uint32_t h, + const void *pixels, + size_t byte_count) -> void { + if (!is_valid()) { + throw std::runtime_error( + "VulkanTexture::update_region: texture not created"); + } + + size_t bpp = bytes_per_pixel(); + size_t expected = static_cast(w) * h * bpp; + if (byte_count < expected) { + throw std::runtime_error( + "VulkanTexture::update_region: insufficient data"); + } + + // Copy region data into the staging buffer at offset 0. + void *mapped = _device.mapMemory(_staging_memory, 0, expected); + std::memcpy(mapped, pixels, expected); + _device.unmapMemory(_staging_memory); + + one_shot_command(film, [&](vk::CommandBuffer cmd) { + // SHADER_READ_ONLY -> TRANSFER_DST + transition_layout(cmd, + vk::ImageLayout::eShaderReadOnlyOptimal, + vk::ImageLayout::eTransferDstOptimal); + + // Copy staging -> sub-region of image. + vk::BufferImageCopy region; + region.setBufferOffset(0); + region.setBufferRowLength(w); + region.setBufferImageHeight(h); + region.imageSubresource.setAspectMask(vk::ImageAspectFlagBits::eColor); + region.imageSubresource.setMipLevel(0); + region.imageSubresource.setBaseArrayLayer(0); + region.imageSubresource.setLayerCount(1); + region.setImageOffset( + {static_cast(x), static_cast(y), 0}); + region.setImageExtent({w, h, 1}); + + cmd.copyBufferToImage(_staging_buffer, + _image, + vk::ImageLayout::eTransferDstOptimal, + {region}); + + // TRANSFER_DST -> SHADER_READ_ONLY + transition_layout(cmd, + vk::ImageLayout::eTransferDstOptimal, + vk::ImageLayout::eShaderReadOnlyOptimal); + }); +} + +} // namespace balsa::visualization::vulkan diff --git a/visualization/src/vulkan/vulkan_image_drawable.cpp b/visualization/src/vulkan/vulkan_image_drawable.cpp new file mode 100644 index 0000000..950fc7c --- /dev/null +++ b/visualization/src/vulkan/vulkan_image_drawable.cpp @@ -0,0 +1,252 @@ +#include "balsa/visualization/vulkan/vulkan_image_drawable.hpp" +#include "balsa/scene_graph/ImageData.hpp" +#include "balsa/scene_graph/Object.hpp" +#include "balsa/scene_graph/types.hpp" +#include "balsa/visualization/vulkan/film.hpp" +#include "balsa/visualization/vulkan/image_pipeline.hpp" + +#include + +namespace balsa::visualization::vulkan { + +// ── Constructor / Destructor ──────────────────────────────────────── + +VulkanImageDrawable::VulkanImageDrawable(scene_graph::DrawableGroup &group, + ImagePipelineManager &manager) + : VulkanDrawable(group), _manager(&manager) {} + +VulkanImageDrawable::~VulkanImageDrawable() { release(); } + +// ── Lifecycle ─────────────────────────────────────────────────────── + +auto VulkanImageDrawable::init(Film &film) -> void { + if (_initialized) return; + + const int frame_count = film.concurrent_frame_count(); + + _transform_ubos.resize(frame_count); + _params_ubos.resize(frame_count); + _descriptor_sets.resize(frame_count); + + for (int i = 0; i < frame_count; ++i) { + // Create host-visible UBO buffers. + _transform_ubos[i] = + VulkanBuffer(film, + sizeof(ImageTransformUBO), + vk::BufferUsageFlagBits::eUniformBuffer, + vk::MemoryPropertyFlagBits::eHostVisible + | vk::MemoryPropertyFlagBits::eHostCoherent); + + _params_ubos[i] = + VulkanBuffer(film, + sizeof(ImageParamsUBO), + vk::BufferUsageFlagBits::eUniformBuffer, + vk::MemoryPropertyFlagBits::eHostVisible + | vk::MemoryPropertyFlagBits::eHostCoherent); + + // Allocate descriptor set. We cannot write it yet because the + // texture may not exist. We will write/update descriptors in + // sync_from_image_data() once the texture is created. + _descriptor_sets[i] = _manager->allocate_descriptor_set(); + } + + _initialized = true; +} + +auto VulkanImageDrawable::release() -> void { + _texture.release(); + + for (auto &ubo : _transform_ubos) ubo.release(); + _transform_ubos.clear(); + + for (auto &ubo : _params_ubos) ubo.release(); + _params_ubos.clear(); + + if (_manager) { + for (auto ds : _descriptor_sets) { + if (ds) _manager->free_descriptor_set(ds); + } + } + _descriptor_sets.clear(); + + _synced_version = 0; + _initialized = false; +} + +// ── VulkanDrawable interface ──────────────────────────────────────── + +auto VulkanImageDrawable::draw(const scene_graph::Camera &cam, Film &film) + -> void { + if (!_initialized) return; + if (!object().visible) return; + + // Sync GPU texture from ImageData if dirty. + sync_from_image_data(film); + + if (!_texture.is_valid()) return; + + // Upload UBOs for this frame. + update_ubos(cam, film); + + // Record draw commands. + record_draw_commands(film); +} + +// ── Private: sync ─────────────────────────────────────────────────── + +auto VulkanImageDrawable::sync_from_image_data(Film &film) -> void { + auto *image_data = object().find_feature(); + if (!image_data) return; + if (!image_data->has_pixels()) return; + if (image_data->version() == _synced_version) return; + + uint32_t w = image_data->width(); + uint32_t h = image_data->height(); + auto format = static_cast(image_data->format()); + + // Check if we need to (re)create the texture (dimensions or format + // changed). + bool need_recreate = !_texture.is_valid() || _texture.width() != w + || _texture.height() != h + || _texture.format() != format; + + if (need_recreate) { + _texture.create(film, w, h, format); + + // Upload the full image. + auto pixels = image_data->pixels(); + _texture.upload(film, pixels.data(), pixels.size()); + + // Write descriptor sets now that texture exists. + for (int i = 0; i < static_cast(_descriptor_sets.size()); ++i) { + _manager->write_descriptor_set(_descriptor_sets[i], + _transform_ubos[i].buffer(), + sizeof(ImageTransformUBO), + _params_ubos[i].buffer(), + sizeof(ImageParamsUBO), + _texture.image_view(), + _texture.sampler()); + } + } else { + // Texture exists and dimensions match — check for partial vs full + // update. + auto dirty = image_data->dirty_region(); + if (dirty && !image_data->is_full_dirty()) { + // Partial update: extract the dirty sub-region from the pixel + // buffer and upload just that rectangle. + size_t bpp = image_data->bytes_per_pixel(); + uint32_t dw = dirty->width(); + uint32_t dh = dirty->height(); + uint32_t dx = dirty->min(0); + uint32_t dy = dirty->min(1); + size_t region_bytes = static_cast(dw) * dh * bpp; + size_t row_bytes = static_cast(dw) * bpp; + + // Build a tightly-packed buffer for the dirty region. + std::vector region_data(region_bytes); + auto pixels = image_data->pixels(); + for (uint32_t row = 0; row < dh; ++row) { + size_t src_offset = + (static_cast(dy + row) * w + dx) * bpp; + size_t dst_offset = static_cast(row) * row_bytes; + std::memcpy(region_data.data() + dst_offset, + pixels.data() + src_offset, + row_bytes); + } + + _texture.update_region( + film, dx, dy, dw, dh, region_data.data(), region_data.size()); + } else { + // Full re-upload. + auto pixels = image_data->pixels(); + _texture.upload(film, pixels.data(), pixels.size()); + } + } + + image_data->clear_dirty(); + _synced_version = image_data->version(); +} + +// ── Private: UBO update ───────────────────────────────────────────── + +auto VulkanImageDrawable::update_ubos(const scene_graph::Camera &cam, + Film &film) -> void { + if (!_initialized) return; + + const int fi = film.current_frame(); + + // ── TransformUBO (MVP) ────────────────────────────────────────── + ImageTransformUBO transform; + if (_has_mvp_override) { + transform.mvp = _mvp_override; + } else { + // Compute MVP from camera + object world transform. + auto model = object().world_transform().to_matrix(); + auto view = cam.view_matrix(); + auto projection = cam.projection_matrix(); + transform.mvp = (projection * view * model).eval(); + } + _transform_ubos[fi].upload(&transform, sizeof(ImageTransformUBO)); + + // ── ImageParamsUBO (tone mapping + image dimensions) ──────────── + auto *image_data = object().find_feature(); + + ImageParamsUBO params; + float exposure = image_data ? image_data->exposure() : 0.0f; + float gamma = image_data ? image_data->gamma() : 2.2f; + float channel = + image_data ? static_cast(image_data->channel_mode()) : 0.0f; + float img_w = image_data ? static_cast(image_data->width()) : 1.0f; + float img_h = image_data ? static_cast(image_data->height()) : 1.0f; + + params.tone_params(0) = exposure; + params.tone_params(1) = gamma; + params.tone_params(2) = channel; + params.tone_params(3) = 0.0f; + + params.image_size(0) = img_w; + params.image_size(1) = img_h; + params.image_size(2) = (img_w > 0.0f) ? 1.0f / img_w : 0.0f; + params.image_size(3) = (img_h > 0.0f) ? 1.0f / img_h : 0.0f; + + _params_ubos[fi].upload(¶ms, sizeof(ImageParamsUBO)); +} + +// ── Private: draw commands ────────────────────────────────────────── + +auto VulkanImageDrawable::record_draw_commands(Film &film) -> void { + auto cb = film.current_command_buffer(); + const int fi = film.current_frame(); + + auto pipeline = _manager->get_or_create(film); + if (!pipeline) return; + + auto extent = film.swapchain_image_size(); + + vk::Viewport viewport; + viewport.setX(0.0f); + viewport.setY(0.0f); + viewport.setWidth(static_cast(extent[0])); + viewport.setHeight(static_cast(extent[1])); + viewport.setMinDepth(0.0f); + viewport.setMaxDepth(1.0f); + + vk::Rect2D scissor; + scissor.setOffset({0, 0}); + scissor.setExtent({extent[0], extent[1]}); + + cb.bindPipeline(vk::PipelineBindPoint::eGraphics, pipeline); + cb.setViewport(0, {viewport}); + cb.setScissor(0, {scissor}); + + cb.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, + _manager->pipeline_layout(), + 0, + {_descriptor_sets[fi]}, + {}); + + // Fullscreen triangle: 3 vertices, no vertex buffer. + cb.draw(3, 1, 0, 0); +} + +} // namespace balsa::visualization::vulkan diff --git a/visualization/src/vulkan/vulkan_mesh_drawable.cpp b/visualization/src/vulkan/vulkan_mesh_drawable.cpp index 27b6eb7..eeacf95 100644 --- a/visualization/src/vulkan/vulkan_mesh_drawable.cpp +++ b/visualization/src/vulkan/vulkan_mesh_drawable.cpp @@ -1,9 +1,9 @@ #include "balsa/visualization/vulkan/vulkan_mesh_drawable.hpp" -#include "balsa/visualization/vulkan/film.hpp" -#include "balsa/visualization/vulkan/mesh_pipeline.hpp" #include "balsa/scene_graph/MeshData.hpp" #include "balsa/scene_graph/Object.hpp" #include "balsa/scene_graph/types.hpp" +#include "balsa/visualization/vulkan/film.hpp" +#include "balsa/visualization/vulkan/mesh_pipeline.hpp" #include @@ -13,13 +13,9 @@ namespace balsa::visualization::vulkan { VulkanMeshDrawable::VulkanMeshDrawable(scene_graph::DrawableGroup &group, MeshPipelineManager &manager) - : VulkanDrawable(group), - _manager(&manager) { -} + : VulkanDrawable(group), _manager(&manager) {} -VulkanMeshDrawable::~VulkanMeshDrawable() { - release(); -} +VulkanMeshDrawable::~VulkanMeshDrawable() { release(); } // ── Lifecycle ─────────────────────────────────────────────────────── @@ -29,7 +25,8 @@ void VulkanMeshDrawable::init(Film &film) { const int frame_count = film.concurrent_frame_count(); // Material UBO alignment — shared across all frames. - auto min_align = film.physical_device_properties().limits.minUniformBufferOffsetAlignment; + auto min_align = film.physical_device_properties() + .limits.minUniformBufferOffsetAlignment; _material_ubo_stride = material_ubo_aligned_size(min_align); _transform_ubos.resize(frame_count); @@ -38,27 +35,28 @@ void VulkanMeshDrawable::init(Film &film) { for (int i = 0; i < frame_count; ++i) { // Create host-visible UBO buffers. - _transform_ubos[i] = VulkanBuffer( - film, - sizeof(TransformUBO), - vk::BufferUsageFlagBits::eUniformBuffer, - vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent); - - _material_ubos[i] = VulkanBuffer( - film, - _material_ubo_stride * k_max_material_layers, - vk::BufferUsageFlagBits::eUniformBuffer, - vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent); + _transform_ubos[i] = + VulkanBuffer(film, + sizeof(TransformUBO), + vk::BufferUsageFlagBits::eUniformBuffer, + vk::MemoryPropertyFlagBits::eHostVisible + | vk::MemoryPropertyFlagBits::eHostCoherent); + + _material_ubos[i] = + VulkanBuffer(film, + _material_ubo_stride * k_max_material_layers, + vk::BufferUsageFlagBits::eUniformBuffer, + vk::MemoryPropertyFlagBits::eHostVisible + | vk::MemoryPropertyFlagBits::eHostCoherent); // Allocate and write descriptor set. The material range covers // a single slot; the dynamic offset selects which slot to read. _descriptor_sets[i] = _manager->allocate_descriptor_set(); - _manager->write_descriptor_set( - _descriptor_sets[i], - _transform_ubos[i].buffer(), - sizeof(TransformUBO), - _material_ubos[i].buffer(), - sizeof(MaterialUBO)); + _manager->write_descriptor_set(_descriptor_sets[i], + _transform_ubos[i].buffer(), + sizeof(TransformUBO), + _material_ubos[i].buffer(), + sizeof(MaterialUBO)); } _initialized = true; @@ -113,51 +111,48 @@ void VulkanMeshDrawable::sync_from_mesh_data(Film &film) { if (!mesh_data) return; if (mesh_data->version() == _synced_version) return; - // Upload positions — reinterpret Vec3f[] as contiguous float[]. - if (mesh_data->has_positions()) { - auto positions = mesh_data->positions(); - auto float_span = std::span( - reinterpret_cast(positions.data()), - positions.size() * 3); - _buffers.upload_positions( - film, float_span, static_cast(positions.size())); + // Upload positions from the role binding's GPU-ready data. + const auto &pos = mesh_data->position_binding(); + if (pos.is_bound()) { + const void *data = pos.raw_data(); + std::size_t byte_size = + pos.size() * pos.component_count * sizeof(float); + _buffers.upload_positions(film, + data, + byte_size, + static_cast(pos.size()), + pos.component_count); } // Upload normals. - if (mesh_data->has_normals()) { - auto normals = mesh_data->normals(); - auto float_span = std::span( - reinterpret_cast(normals.data()), - normals.size() * 3); - _buffers.upload_normals(film, float_span); - } - - // Upload per-vertex colors (Vec4f → 4 floats, RGBA). - if (mesh_data->has_vertex_colors()) { - auto colors = mesh_data->vertex_colors(); - auto float_span = std::span( - reinterpret_cast(colors.data()), - colors.size() * 4); - _buffers.upload_colors(film, float_span); + const auto &nrm = mesh_data->normal_binding(); + if (nrm.is_bound()) { + const void *data = nrm.raw_data(); + std::size_t byte_size = + nrm.size() * nrm.component_count * sizeof(float); + _buffers.upload_normals(film, data, byte_size, nrm.component_count); } // Upload scalar field. - if (mesh_data->has_scalar_field()) { - _buffers.upload_scalars(film, mesh_data->scalar_field()); + const auto &scl = mesh_data->scalar_binding(); + if (scl.is_bound()) { + const auto *fdata = static_cast(scl.raw_data()); + _buffers.upload_scalars(film, + std::span(fdata, scl.size())); } // Upload triangle indices. if (mesh_data->has_triangle_indices()) { auto tri_idx = mesh_data->triangle_indices(); _buffers.upload_triangle_indices( - film, tri_idx, static_cast(tri_idx.size() / 3)); + film, tri_idx, static_cast(tri_idx.size() / 3)); } // Upload edge indices. if (mesh_data->has_edge_indices()) { auto edge_idx = mesh_data->edge_indices(); _buffers.upload_edge_indices( - film, edge_idx, static_cast(edge_idx.size() / 2)); + film, edge_idx, static_cast(edge_idx.size() / 2)); } _synced_version = mesh_data->version(); @@ -165,7 +160,8 @@ void VulkanMeshDrawable::sync_from_mesh_data(Film &film) { // ── Private: transform UBO ────────────────────────────────────────── -void VulkanMeshDrawable::update_transform_ubo(const scene_graph::Camera &cam, Film &film) { +void VulkanMeshDrawable::update_transform_ubo(const scene_graph::Camera &cam, + Film &film) { if (!_initialized) return; const int fi = film.current_frame(); @@ -177,7 +173,8 @@ void VulkanMeshDrawable::update_transform_ubo(const scene_graph::Camera &cam, Fi auto projection = cam.projection_matrix(); // Inverse-transpose of model (for transforming normals). - scene_graph::Mat4f normal_matrix = model_affine.inverse().to_matrix().transpose().eval(); + scene_graph::Mat4f normal_matrix = + model_affine.inverse().to_matrix().transpose().eval(); // Camera world-space position (for specular view vector). auto cam_world = cam.object().world_transform(); @@ -191,20 +188,19 @@ void VulkanMeshDrawable::update_transform_ubo(const scene_graph::Camera &cam, Fi transform.camera_pos(0) = static_cast(cam_translation(0)); transform.camera_pos(1) = static_cast(cam_translation(1)); transform.camera_pos(2) = static_cast(cam_translation(2)); - transform.camera_pos(3) = 0.0f;// pad + transform.camera_pos(3) = 0.0f; // pad _transform_ubos[fi].upload(&transform, sizeof(TransformUBO)); } // ── Private: per-layer material UBO ───────────────────────────────── void VulkanMeshDrawable::upload_material_ubo_for_layer( - Film &film, - uint32_t layer_slot, - const float layer_color[4], - float point_size, - const MeshRenderState &rs, - const float *wireframe_color_override) { - + Film &film, + uint32_t layer_slot, + const float layer_color[4], + float point_size, + const MeshRenderState &rs, + const float *wireframe_color_override) { const int fi = film.current_frame(); MaterialUBO material; @@ -251,40 +247,39 @@ void VulkanMeshDrawable::upload_material_ubo_for_layer( // (merged solid+wireframe pass), use the wireframe color here // so the fragment shader can read it from u_layer_color for the // wireframe overlay blend. - const float *lc = wireframe_color_override ? wireframe_color_override : layer_color; + const float *lc = + wireframe_color_override ? wireframe_color_override : layer_color; material.layer_color(0) = lc[0]; material.layer_color(1) = lc[1]; material.layer_color(2) = lc[2]; material.layer_color(3) = lc[3]; // Write to the slot's aligned offset within the multi-slot buffer. - vk::DeviceSize offset = static_cast(layer_slot) * _material_ubo_stride; + vk::DeviceSize offset = + static_cast(layer_slot) * _material_ubo_stride; _material_ubos[fi].upload(&material, sizeof(MaterialUBO), offset); } // ── Private: bind descriptor set with dynamic offset ──────────────── -void VulkanMeshDrawable::bind_descriptor_set_for_layer( - Film &film, - vk::CommandBuffer cb, - uint32_t layer_slot) { +void VulkanMeshDrawable::bind_descriptor_set_for_layer(Film &film, + vk::CommandBuffer cb, + uint32_t layer_slot) { const int fi = film.current_frame(); uint32_t dynamic_offset = static_cast( - static_cast(layer_slot) * _material_ubo_stride); - cb.bindDescriptorSets( - vk::PipelineBindPoint::eGraphics, - _manager->pipeline_layout(), - 0, - { _descriptor_sets[fi] }, - { dynamic_offset }); + static_cast(layer_slot) * _material_ubo_stride); + cb.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, + _manager->pipeline_layout(), + 0, + {_descriptor_sets[fi]}, + {dynamic_offset}); } // ── Private: multi-layer draw commands ────────────────────────────── void VulkanMeshDrawable::record_draw_commands(Film &film) { auto *mesh_data = object().find_feature(); - const auto &rs = mesh_data ? mesh_data->render_state() - : MeshRenderState{}; + const auto &rs = mesh_data ? mesh_data->render_state() : MeshRenderState{}; const auto &layers = rs.layers; auto cb = film.current_command_buffer(); @@ -302,26 +297,30 @@ void VulkanMeshDrawable::record_draw_commands(Film &film) { viewport.setMaxDepth(1.0f); vk::Rect2D scissor; - scissor.setOffset({ 0, 0 }); - scissor.setExtent({ extent[0], extent[1] }); + scissor.setOffset({0, 0}); + scissor.setExtent({extent[0], extent[1]}); // Bind pipeline + viewport/scissor + vertex buffers (shared across // all layers). Does NOT bind the descriptor set — that happens // per-layer via bind_descriptor_set_for_layer(). auto bind_shared_state = [&](vk::Pipeline pipeline) { cb.bindPipeline(vk::PipelineBindPoint::eGraphics, pipeline); - cb.setViewport(0, { viewport }); - cb.setScissor(0, { scissor }); + cb.setViewport(0, {viewport}); + cb.setScissor(0, {scissor}); - cb.bindVertexBuffers(0, { _buffers.positions_buffer() }, { vk::DeviceSize{ 0 } }); + cb.bindVertexBuffers( + 0, {_buffers.positions_buffer()}, {vk::DeviceSize{0}}); if (_buffers.has_normals()) { - cb.bindVertexBuffers(1, { _buffers.normals_buffer() }, { vk::DeviceSize{ 0 } }); + cb.bindVertexBuffers( + 1, {_buffers.normals_buffer()}, {vk::DeviceSize{0}}); } if (_buffers.has_colors()) { - cb.bindVertexBuffers(2, { _buffers.colors_buffer() }, { vk::DeviceSize{ 0 } }); + cb.bindVertexBuffers( + 2, {_buffers.colors_buffer()}, {vk::DeviceSize{0}}); } if (_buffers.has_scalars()) { - cb.bindVertexBuffers(3, { _buffers.scalars_buffer() }, { vk::DeviceSize{ 0 } }); + cb.bindVertexBuffers( + 3, {_buffers.scalars_buffer()}, {vk::DeviceSize{0}}); } }; @@ -335,11 +334,9 @@ void VulkanMeshDrawable::record_draw_commands(Film &film) { // Otherwise fall back to the existing two-pass approach: solid // triangles + line-based wireframe from the edge index buffer. - const bool use_merged = - layers.solid.enabled - && layers.wireframe.enabled - && _buffers.has_triangle_indices() - && film.has_fragment_shader_barycentric(); + const bool use_merged = layers.solid.enabled && layers.wireframe.enabled + && _buffers.has_triangle_indices() + && film.has_fragment_shader_barycentric(); // Upload all enabled layers' material data BEFORE recording any // draw commands. Each layer writes to its own aligned slot in @@ -348,37 +345,49 @@ void VulkanMeshDrawable::record_draw_commands(Film &film) { // Layer slots: 0 = solid, 1 = wireframe, 2 = points. if (use_merged) { // Merged pass: slot 0 carries solid lighting + wireframe overlay. - // u_uniform_color = solid base color (slot 0 override for COLOR_UNIFORM) - // u_layer_color = wireframe color (via wireframe_color_override) - // u_scalar_params.z = wireframe width (passed as point_size) - upload_material_ubo_for_layer( - film, 0, layers.solid.color, layers.wireframe.width, rs, layers.wireframe.color); + // u_uniform_color = solid base color (slot 0 override for + // COLOR_UNIFORM) u_layer_color = wireframe color (via + // wireframe_color_override) u_scalar_params.z = wireframe width + // (passed as point_size) + upload_material_ubo_for_layer(film, + 0, + layers.solid.color, + layers.wireframe.width, + rs, + layers.wireframe.color); } else { // Separate passes. if (layers.solid.enabled && _buffers.has_triangle_indices()) { - upload_material_ubo_for_layer(film, 0, layers.solid.color, layers.points.size, rs); + upload_material_ubo_for_layer( + film, 0, layers.solid.color, layers.points.size, rs); } if (layers.wireframe.enabled && _buffers.has_edge_indices()) { - upload_material_ubo_for_layer(film, 1, layers.wireframe.color, layers.points.size, rs); + upload_material_ubo_for_layer( + film, 1, layers.wireframe.color, layers.points.size, rs); } } if (layers.points.enabled && _buffers.vertex_count() > 0) { - upload_material_ubo_for_layer(film, 2, layers.points.color, layers.points.size, rs); + upload_material_ubo_for_layer( + film, 2, layers.points.color, layers.points.size, rs); } // ── Merged solid + wireframe (barycentric overlay) ────────────── if (use_merged) { - auto pipeline = _manager->get_or_create( - rs, vk::PrimitiveTopology::eTriangleList, _buffers, film, - /*wireframe_overlay=*/true); + auto pipeline = + _manager->get_or_create(rs, + vk::PrimitiveTopology::eTriangleList, + _buffers, + film, + /*wireframe_overlay=*/true); if (pipeline) { bind_shared_state(pipeline); bind_descriptor_set_for_layer(film, cb, 0); cb.setLineWidth(1.0f); cb.setDepthBias(0.0f, 0.0f, 0.0f); - cb.bindIndexBuffer(_buffers.triangle_index_buffer(), 0, vk::IndexType::eUint32); + cb.bindIndexBuffer( + _buffers.triangle_index_buffer(), 0, vk::IndexType::eUint32); cb.drawIndexed(_buffers.triangle_count() * 3, 1, 0, 0, 0); } } else { @@ -386,7 +395,7 @@ void VulkanMeshDrawable::record_draw_commands(Film &film) { if (layers.solid.enabled && _buffers.has_triangle_indices()) { auto pipeline = _manager->get_or_create( - rs, vk::PrimitiveTopology::eTriangleList, _buffers, film); + rs, vk::PrimitiveTopology::eTriangleList, _buffers, film); if (pipeline) { bind_shared_state(pipeline); bind_descriptor_set_for_layer(film, cb, 0); @@ -401,7 +410,9 @@ void VulkanMeshDrawable::record_draw_commands(Film &film) { cb.setDepthBias(0.0f, 0.0f, 0.0f); } - cb.bindIndexBuffer(_buffers.triangle_index_buffer(), 0, vk::IndexType::eUint32); + cb.bindIndexBuffer(_buffers.triangle_index_buffer(), + 0, + vk::IndexType::eUint32); cb.drawIndexed(_buffers.triangle_count() * 3, 1, 0, 0, 0); } } @@ -410,14 +421,15 @@ void VulkanMeshDrawable::record_draw_commands(Film &film) { if (layers.wireframe.enabled && _buffers.has_edge_indices()) { auto pipeline = _manager->get_or_create( - rs, vk::PrimitiveTopology::eLineList, _buffers, film); + rs, vk::PrimitiveTopology::eLineList, _buffers, film); if (pipeline) { bind_shared_state(pipeline); bind_descriptor_set_for_layer(film, cb, 1); cb.setLineWidth(layers.wireframe.width); cb.setDepthBias(0.0f, 0.0f, 0.0f); - cb.bindIndexBuffer(_buffers.edge_index_buffer(), 0, vk::IndexType::eUint32); + cb.bindIndexBuffer( + _buffers.edge_index_buffer(), 0, vk::IndexType::eUint32); cb.drawIndexed(_buffers.edge_count() * 2, 1, 0, 0, 0); } } @@ -427,7 +439,7 @@ void VulkanMeshDrawable::record_draw_commands(Film &film) { if (layers.points.enabled && _buffers.vertex_count() > 0) { auto pipeline = _manager->get_or_create( - rs, vk::PrimitiveTopology::ePointList, _buffers, film); + rs, vk::PrimitiveTopology::ePointList, _buffers, film); if (pipeline) { bind_shared_state(pipeline); bind_descriptor_set_for_layer(film, cb, 2); @@ -439,4 +451,4 @@ void VulkanMeshDrawable::record_draw_commands(Film &film) { } } -}// namespace balsa::visualization::vulkan +} // namespace balsa::visualization::vulkan diff --git a/visualization/tests/test_scene_graph.cpp b/visualization/tests/test_scene_graph.cpp index 3bb12f2..f4d14e4 100644 --- a/visualization/tests/test_scene_graph.cpp +++ b/visualization/tests/test_scene_graph.cpp @@ -1,15 +1,91 @@ #define CATCH_CONFIG_MAIN #include -#include #include +#include #include +#include #include +#include + +#include #include #include #include +#include +#include +#include + +// ── Helper: create a quiver Mesh<2> with positions and normals ────── +// +// Builds a two-triangle quad (vertices 0–3) as a Mesh<2> with +// array vertex_positions and optionally vertex_normals. + +static auto make_quad_mesh(bool with_normals = false) + -> std::shared_ptr> { + using Vec3f = std::array; + + // Two triangles: (0,1,2) and (0,2,3) + std::vector> tris = {{0, 1, 2}, {0, 2, 3}}; + auto mesh = std::make_shared>( + quiver::Mesh<2>::from_vertex_indices(tris)); + mesh->build_all_skeletons(); + + // Create vertex positions. + auto pos = mesh->create_attribute("vertex_positions", 0); + pos[0] = {0.0f, 0.0f, 0.0f}; + pos[1] = {1.0f, 0.0f, 0.0f}; + pos[2] = {1.0f, 1.0f, 0.0f}; + pos[3] = {0.0f, 1.0f, 0.0f}; + + if (with_normals) { + auto nrm = mesh->create_attribute("vertex_normals", 0); + for (std::size_t i = 0; i < 4; ++i) { nrm[i] = {0.0f, 0.0f, 1.0f}; } + } + + return mesh; +} + +// ── Helper: create a single-triangle mesh ─────────────────────────── + +static auto make_single_tri_mesh() -> std::shared_ptr> { + using Vec3f = std::array; + + std::vector> tris = {{0, 1, 2}}; + auto mesh = std::make_shared>( + quiver::Mesh<2>::from_vertex_indices(tris)); + mesh->build_all_skeletons(); + + auto pos = mesh->create_attribute("vertex_positions", 0); + pos[0] = {0.0f, 0.0f, 0.0f}; + pos[1] = {1.0f, 0.0f, 0.0f}; + pos[2] = {0.0f, 1.0f, 0.0f}; + + return mesh; +} + +// ── Helper: create a Mesh<1> (edges only, no triangles) ───────────── + +static auto make_edge_mesh() -> std::shared_ptr> { + using Vec3f = std::array; + + std::vector> edges = {{0, 1}, {1, 2}}; + auto mesh = std::make_shared>( + quiver::Mesh<1>::from_vertex_indices(edges)); + mesh->build_all_skeletons(); + + auto pos = mesh->create_attribute("vertex_positions", 0); + pos[0] = {0.0f, 0.0f, 0.0f}; + pos[1] = {1.0f, 0.0f, 0.0f}; + pos[2] = {2.0f, 0.0f, 0.0f}; + + // Add explicit edge positions (4 vertices). + // Not needed — the mesh already has positions on its 3 vertices. + + return mesh; +} TEST_CASE("Object creation and hierarchy", "[scene_graph]") { using namespace balsa::scene_graph; @@ -79,50 +155,21 @@ TEST_CASE("MeshData geometry and version tracking", "[scene_graph]") { CHECK_FALSE(md.has_normals()); CHECK_FALSE(md.has_triangle_indices()); CHECK_FALSE(md.has_edge_indices()); - CHECK_FALSE(md.has_vertex_colors()); CHECK_FALSE(md.has_scalar_field()); uint64_t v0 = md.version(); - // Set positions - std::vector positions(4); - for (auto &p : positions) { - p(0) = 0.0f; - p(1) = 0.0f; - p(2) = 0.0f; - } - md.set_positions(positions); + // Set mesh with positions and normals. + auto mesh = make_quad_mesh(/*with_normals=*/true); + md.set_mesh(mesh); + CHECK(md.vertex_count() == 4); CHECK(md.has_positions()); - CHECK(md.version() > v0); - uint64_t v1 = md.version(); - - // Set triangle indices - std::vector tri_idx = { 0, 1, 2, 0, 2, 3 }; - md.set_triangle_indices(tri_idx); - CHECK(md.triangle_count() == 2); + CHECK(md.has_normals()); CHECK(md.has_triangle_indices()); - CHECK(md.version() > v1); - uint64_t v2 = md.version(); - - // Set edge indices - std::vector edge_idx = { 0, 1, 1, 2, 2, 3, 3, 0 }; - md.set_edge_indices(edge_idx); - CHECK(md.edge_count() == 4); + CHECK(md.triangle_count() == 2); CHECK(md.has_edge_indices()); - CHECK(md.version() > v2); - uint64_t v3 = md.version(); - - // Set normals - std::vector normals(4); - for (auto &n : normals) { - n(0) = 0.0f; - n(1) = 0.0f; - n(2) = 1.0f; - } - md.set_normals(normals); - CHECK(md.has_normals()); - CHECK(md.version() > v3); + CHECK(md.version() > v0); } TEST_CASE("MeshData auto-derives edges from triangles", "[scene_graph]") { @@ -130,81 +177,123 @@ TEST_CASE("MeshData auto-derives edges from triangles", "[scene_graph]") { MeshData md; - // Set triangle indices for two triangles sharing an edge: + // Two triangles sharing an edge: // tri 0: (0, 1, 2) // tri 1: (0, 2, 3) // // Expected unique edges: 0-1, 1-2, 0-2, 2-3, 0-3 = 5 edges - std::vector tri_idx = { 0, 1, 2, 0, 2, 3 }; - md.set_triangle_indices(tri_idx); + auto mesh = make_quad_mesh(); + md.set_mesh(mesh); CHECK(md.has_triangle_indices()); CHECK(md.triangle_count() == 2); - CHECK(md.has_topology()); - // Edges should be auto-derived + // Edges should be auto-derived from the mesh skeleton. CHECK(md.has_edge_indices()); CHECK(md.edge_count() == 5); - // Verify edge indices are valid vertex references + // Verify edge indices are valid vertex references. auto edges = md.edge_indices(); - for (std::size_t i = 0; i < edges.size(); ++i) { - CHECK(edges[i] < 4); - } + for (std::size_t i = 0; i < edges.size(); ++i) { CHECK(edges[i] < 4); } } -TEST_CASE("MeshData explicit edges override auto-derived", "[scene_graph]") { +TEST_CASE("MeshData topology from single triangle", "[scene_graph]") { using namespace balsa::scene_graph; MeshData md; - // Set triangles first (auto-derives 5 edges) - std::vector tri_idx = { 0, 1, 2, 0, 2, 3 }; - md.set_triangle_indices(tri_idx); - CHECK(md.edge_count() == 5); + auto mesh = make_single_tri_mesh(); + md.set_mesh(mesh); - // Explicitly set fewer edges — should override - std::vector edge_idx = { 0, 1, 2, 3 }; - md.set_edge_indices(edge_idx); - CHECK(md.edge_count() == 2); - - // Setting triangles again should NOT override explicit edges - std::vector tri_idx2 = { 0, 1, 2 }; - md.set_triangle_indices(tri_idx2); - CHECK(md.edge_count() == 2);// Still the explicit edges + CHECK(md.triangle_count() == 1); + CHECK(md.edge_count() == 3); // A single triangle has 3 edges } -TEST_CASE("MeshData topology from single triangle", "[scene_graph]") { +TEST_CASE("MeshData edge-only mesh has no triangles", "[scene_graph]") { using namespace balsa::scene_graph; MeshData md; - std::vector tri_idx = { 0, 1, 2 }; - md.set_triangle_indices(tri_idx); + auto mesh = make_edge_mesh(); + md.set_mesh(mesh); - CHECK(md.has_topology()); - CHECK(md.triangle_count() == 1); - CHECK(md.edge_count() == 3);// A single triangle has 3 edges + // Edge mesh: has edges but no triangles. + CHECK_FALSE(md.has_triangle_indices()); + CHECK(md.has_edge_indices()); + CHECK(md.edge_count() == 2); + CHECK(md.has_positions()); + CHECK(md.vertex_count() == 3); } -TEST_CASE("MeshData no topology without triangles", "[scene_graph]") { +TEST_CASE("MeshData role assignment and clearing", "[scene_graph]") { using namespace balsa::scene_graph; MeshData md; + auto mesh = make_quad_mesh(/*with_normals=*/true); + md.set_mesh(mesh); + + // set_mesh auto-assigns by convention name. + CHECK(md.has_positions()); + CHECK(md.has_normals()); + CHECK_FALSE(md.has_scalar_field()); + + uint64_t v1 = md.version(); - // Only set positions and explicit edges — no topology - std::vector positions(4); - for (auto &p : positions) { - p(0) = p(1) = p(2) = 0.0f; + // Clear positions. + md.clear_position(); + CHECK_FALSE(md.has_positions()); + CHECK(md.version() > v1); + + // Clear normals. + uint64_t v2 = md.version(); + md.clear_normal(); + CHECK_FALSE(md.has_normals()); + CHECK(md.version() > v2); + + // Re-assign position from discovered attributes. + uint64_t v3 = md.version(); + const auto &discovered = md.discovered_attributes(); + REQUIRE(!discovered.empty()); + for (const auto &da : discovered) { + if (da.name == "vertex_positions") { + md.assign_position(da.handle); + break; + } } - md.set_positions(positions); + CHECK(md.has_positions()); + CHECK(md.version() > v3); +} - std::vector edge_idx = { 0, 1, 1, 2 }; - md.set_edge_indices(edge_idx); +TEST_CASE("MeshData discovered attributes", "[scene_graph]") { + using namespace balsa::scene_graph; - CHECK_FALSE(md.has_topology()); - CHECK(md.has_edge_indices()); - CHECK(md.edge_count() == 2); + MeshData md; + auto mesh = make_quad_mesh(/*with_normals=*/true); + md.set_mesh(mesh); + + const auto &discovered = md.discovered_attributes(); + + // Should have at least vertex_positions and vertex_normals. + bool found_pos = false; + bool found_nrm = false; + for (const auto &da : discovered) { + if (da.name == "vertex_positions") { + found_pos = true; + CHECK(da.dimension == 0); + CHECK(da.component_count == 3); + CHECK(da.is_floating_point); + CHECK(da.count == 4); + } + if (da.name == "vertex_normals") { + found_nrm = true; + CHECK(da.dimension == 0); + CHECK(da.component_count == 3); + CHECK(da.is_floating_point); + CHECK(da.count == 4); + } + } + CHECK(found_pos); + CHECK(found_nrm); } TEST_CASE("MeshData as Object feature", "[scene_graph]") { @@ -213,20 +302,16 @@ TEST_CASE("MeshData as Object feature", "[scene_graph]") { Object obj("mesh_obj"); auto &md = obj.emplace_feature(); - // Verify it can be found via find_feature + // Verify it can be found via find_feature. MeshData *found = obj.find_feature(); REQUIRE(found != nullptr); CHECK(found == &md); - // Set some data and verify through the found pointer - std::vector positions(3); - for (std::size_t i = 0; i < 3; ++i) { - positions[i](0) = static_cast(i); - positions[i](1) = 0.0f; - positions[i](2) = 0.0f; - } - md.set_positions(positions); + // Set mesh and verify through the found pointer. + auto mesh = make_single_tri_mesh(); + md.set_mesh(mesh); CHECK(found->vertex_count() == 3); + CHECK(found->has_positions()); } TEST_CASE("Object detach", "[scene_graph]") { @@ -285,7 +370,8 @@ TEST_CASE("Object default TRS is identity", "[scene_graph][trs]") { for (int r = 0; r < 4; ++r) { for (int c = 0; c < 4; ++c) { float expected = (r == c) ? 1.0f : 0.0f; - CHECK(static_cast(m(r, c)) == Catch::Approx(expected).margin(1e-6f)); + CHECK(static_cast(m(r, c)) + == Catch::Approx(expected).margin(1e-6f)); } } } @@ -314,7 +400,8 @@ TEST_CASE("Object set/get translation", "[scene_graph][trs]") { for (int r = 0; r < 3; ++r) { for (int c = 0; c < 3; ++c) { float expected = (r == c) ? 1.0f : 0.0f; - CHECK(static_cast(m(r, c)) == Catch::Approx(expected).margin(1e-6f)); + CHECK(static_cast(m(r, c)) + == Catch::Approx(expected).margin(1e-6f)); } } } @@ -356,7 +443,8 @@ TEST_CASE("Object set_uniform_scale", "[scene_graph][trs]") { CHECK(static_cast(obj.scale_factors()(2)) == Catch::Approx(5.0f)); } -TEST_CASE("Object translate() adds to current translation", "[scene_graph][trs]") { +TEST_CASE("Object translate() adds to current translation", + "[scene_graph][trs]") { using namespace balsa::scene_graph; Object obj("accum"); @@ -410,9 +498,9 @@ TEST_CASE("Object euler angle round-trip", "[scene_graph][trs]") { Object obj("euler"); Vec3f euler_deg; - euler_deg(0) = 30.0f;// pitch (X) - euler_deg(1) = 45.0f;// yaw (Y) - euler_deg(2) = 60.0f;// roll (Z) + euler_deg(0) = 30.0f; // pitch (X) + euler_deg(1) = 45.0f; // yaw (Y) + euler_deg(2) = 60.0f; // roll (Z) obj.set_rotation_euler(euler_deg); Vec3f result = obj.rotation_euler(); @@ -421,7 +509,8 @@ TEST_CASE("Object euler angle round-trip", "[scene_graph][trs]") { CHECK(static_cast(result(2)) == Catch::Approx(60.0f).margin(0.01f)); } -TEST_CASE("Object euler angle zero is identity rotation", "[scene_graph][trs]") { +TEST_CASE("Object euler angle zero is identity rotation", + "[scene_graph][trs]") { using namespace balsa::scene_graph; Object obj("euler_zero"); @@ -433,10 +522,14 @@ TEST_CASE("Object euler angle zero is identity rotation", "[scene_graph][trs]") obj.set_rotation_euler(zero); // Should produce identity quaternion. - CHECK(static_cast(obj.rotation().w()) == Catch::Approx(1.0f).margin(1e-6f)); - CHECK(static_cast(obj.rotation().x()) == Catch::Approx(0.0f).margin(1e-6f)); - CHECK(static_cast(obj.rotation().y()) == Catch::Approx(0.0f).margin(1e-6f)); - CHECK(static_cast(obj.rotation().z()) == Catch::Approx(0.0f).margin(1e-6f)); + CHECK(static_cast(obj.rotation().w()) + == Catch::Approx(1.0f).margin(1e-6f)); + CHECK(static_cast(obj.rotation().x()) + == Catch::Approx(0.0f).margin(1e-6f)); + CHECK(static_cast(obj.rotation().y()) + == Catch::Approx(0.0f).margin(1e-6f)); + CHECK(static_cast(obj.rotation().z()) + == Catch::Approx(0.0f).margin(1e-6f)); } TEST_CASE("Object reset_transform restores identity", "[scene_graph][trs]") { @@ -511,23 +604,33 @@ TEST_CASE("Object set_from_transform round-trip", "[scene_graph][trs]") { dst.set_from_transform(xf); // Translation should match. - CHECK(static_cast(dst.translation()(0)) == Catch::Approx(3.0f).margin(1e-4f)); - CHECK(static_cast(dst.translation()(1)) == Catch::Approx(-1.0f).margin(1e-4f)); - CHECK(static_cast(dst.translation()(2)) == Catch::Approx(7.0f).margin(1e-4f)); + CHECK(static_cast(dst.translation()(0)) + == Catch::Approx(3.0f).margin(1e-4f)); + CHECK(static_cast(dst.translation()(1)) + == Catch::Approx(-1.0f).margin(1e-4f)); + CHECK(static_cast(dst.translation()(2)) + == Catch::Approx(7.0f).margin(1e-4f)); // Scale should match. - CHECK(static_cast(dst.scale_factors()(0)) == Catch::Approx(2.0f).margin(1e-4f)); - CHECK(static_cast(dst.scale_factors()(1)) == Catch::Approx(0.5f).margin(1e-4f)); - CHECK(static_cast(dst.scale_factors()(2)) == Catch::Approx(1.5f).margin(1e-4f)); + CHECK(static_cast(dst.scale_factors()(0)) + == Catch::Approx(2.0f).margin(1e-4f)); + CHECK(static_cast(dst.scale_factors()(1)) + == Catch::Approx(0.5f).margin(1e-4f)); + CHECK(static_cast(dst.scale_factors()(2)) + == Catch::Approx(1.5f).margin(1e-4f)); // Euler angles should match (check via re-extraction). Vec3f dst_euler = dst.rotation_euler(); - CHECK(static_cast(dst_euler(0)) == Catch::Approx(20.0f).margin(0.1f)); - CHECK(static_cast(dst_euler(1)) == Catch::Approx(35.0f).margin(0.1f)); - CHECK(static_cast(dst_euler(2)) == Catch::Approx(-10.0f).margin(0.1f)); + CHECK(static_cast(dst_euler(0)) + == Catch::Approx(20.0f).margin(0.1f)); + CHECK(static_cast(dst_euler(1)) + == Catch::Approx(35.0f).margin(0.1f)); + CHECK(static_cast(dst_euler(2)) + == Catch::Approx(-10.0f).margin(0.1f)); } -TEST_CASE("Object local_transform composes T*R*S correctly", "[scene_graph][trs]") { +TEST_CASE("Object local_transform composes T*R*S correctly", + "[scene_graph][trs]") { using namespace balsa::scene_graph; Object obj("trs"); @@ -566,7 +669,8 @@ TEST_CASE("Object local_transform composes T*R*S correctly", "[scene_graph][trs] CHECK(static_cast(m(2, 2)) == Catch::Approx(1.0f).margin(1e-5f)); } -TEST_CASE("Object world_transform composes parent chain", "[scene_graph][trs]") { +TEST_CASE("Object world_transform composes parent chain", + "[scene_graph][trs]") { using namespace balsa::scene_graph; Object root("root"); @@ -596,7 +700,8 @@ TEST_CASE("Object world_transform composes parent chain", "[scene_graph][trs]") CHECK(static_cast(child_world(2, 3)) == Catch::Approx(0.0f)); } -TEST_CASE("Object world_transform with rotation propagation", "[scene_graph][trs]") { +TEST_CASE("Object world_transform with rotation propagation", + "[scene_graph][trs]") { using namespace balsa::scene_graph; // Parent has 90° rotation around Z. @@ -616,9 +721,12 @@ TEST_CASE("Object world_transform with rotation propagation", "[scene_graph][trs // In world space, the child's translation should be rotated by // parent's 90°-Z: (1,0,0) → (0,1,0). auto child_world = child.world_transform().to_matrix(); - CHECK(static_cast(child_world(0, 3)) == Catch::Approx(0.0f).margin(1e-5f)); - CHECK(static_cast(child_world(1, 3)) == Catch::Approx(1.0f).margin(1e-5f)); - CHECK(static_cast(child_world(2, 3)) == Catch::Approx(0.0f).margin(1e-5f)); + CHECK(static_cast(child_world(0, 3)) + == Catch::Approx(0.0f).margin(1e-5f)); + CHECK(static_cast(child_world(1, 3)) + == Catch::Approx(1.0f).margin(1e-5f)); + CHECK(static_cast(child_world(2, 3)) + == Catch::Approx(0.0f).margin(1e-5f)); } TEST_CASE("Object selectability flag", "[scene_graph]") { @@ -653,6 +761,346 @@ TEST_CASE("Object reparenting via add_child(unique_ptr)", "[scene_graph]") { CHECK(parent2.children_count() == 1); } +// ── ImageData Tests ──────────────────────────────────────────────── + +TEST_CASE("ImageData default state", "[scene_graph][image]") { + using namespace balsa::scene_graph; + + ImageData img; + CHECK(img.width() == 0); + CHECK(img.height() == 0); + CHECK(img.format() == ImageData::Format::RGBA8); + CHECK_FALSE(img.has_pixels()); + CHECK(img.pixels().empty()); + CHECK(img.version() == 0); + CHECK_FALSE(img.dirty_region().has_value()); + + // Default display parameters. + CHECK(img.exposure() == Catch::Approx(0.0f)); + CHECK(img.gamma() == Catch::Approx(2.2f)); + CHECK(img.channel_mode() == ImageData::ChannelMode::RGBA); +} + +TEST_CASE("ImageData set_pixels_rgba8", "[scene_graph][image]") { + using namespace balsa::scene_graph; + + ImageData img; + uint32_t w = 2, h = 3; + // 2x3 image, RGBA8 = 4 bytes/pixel = 24 bytes + std::vector rgba(w * h * 4, 128); + // Set a recognizable pixel. + rgba[0] = 255; + rgba[1] = 0; + rgba[2] = 0; + rgba[3] = 255; // red + + img.set_pixels_rgba8(w, h, rgba); + + CHECK(img.width() == 2); + CHECK(img.height() == 3); + CHECK(img.format() == ImageData::Format::RGBA8); + CHECK(img.has_pixels()); + CHECK(img.bytes_per_pixel() == 4); + CHECK(img.pixels().size() == 24); + CHECK(img.version() == 1); + + // Dirty region should cover the full image. + auto dirty = img.dirty_region(); + REQUIRE(dirty.has_value()); + CHECK(dirty->min(0) == 0); + CHECK(dirty->min(1) == 0); + CHECK(dirty->width() == 2); + CHECK(dirty->height() == 3); + CHECK(img.is_full_dirty()); + + // Verify pixel data was copied. + auto px = img.pixels(); + CHECK(static_cast(px[0]) == 255); + CHECK(static_cast(px[1]) == 0); + CHECK(static_cast(px[2]) == 0); + CHECK(static_cast(px[3]) == 255); +} + +TEST_CASE("ImageData set_pixels_rgbaf32", "[scene_graph][image]") { + using namespace balsa::scene_graph; + + ImageData img; + uint32_t w = 4, h = 2; + // 4x2 image, RGBAF32 = 16 bytes/pixel = 128 bytes = 32 floats + std::vector rgba(w * h * 4, 0.5f); + rgba[0] = 1.0f; + rgba[1] = 0.0f; + rgba[2] = 0.0f; + rgba[3] = 1.0f; + + img.set_pixels_rgbaf32(w, h, rgba); + + CHECK(img.width() == 4); + CHECK(img.height() == 2); + CHECK(img.format() == ImageData::Format::RGBAF32); + CHECK(img.bytes_per_pixel() == 16); + CHECK(img.pixels().size() == 128); + CHECK(img.version() == 1); + CHECK(img.is_full_dirty()); +} + +TEST_CASE("ImageData version increments on mutations", "[scene_graph][image]") { + using namespace balsa::scene_graph; + + ImageData img; + CHECK(img.version() == 0); + + std::vector rgba(4 * 4 * 4, 200); // 4x4 image + img.set_pixels_rgba8(4, 4, rgba); + CHECK(img.version() == 1); + + // Partial update. + std::vector patch(2 * 2 * 4, 100); // 2x2 patch + auto patch_bytes = std::as_bytes(std::span(patch)); + img.update_region(1, 1, 2, 2, patch_bytes); + CHECK(img.version() == 2); + + // Another set_pixels replaces everything. + img.set_pixels_rgba8(4, 4, rgba); + CHECK(img.version() == 3); +} + +TEST_CASE("ImageData update_region writes correct pixels", + "[scene_graph][image]") { + using namespace balsa::scene_graph; + + ImageData img; + uint32_t w = 4, h = 4; + std::vector rgba(w * h * 4, 0); + img.set_pixels_rgba8(w, h, rgba); + img.clear_dirty(); + + // Write a 2x2 red patch at (1, 1). + std::vector patch(2 * 2 * 4); + for (size_t i = 0; i < 4; ++i) { + patch[i * 4 + 0] = 255; // R + patch[i * 4 + 1] = 0; // G + patch[i * 4 + 2] = 0; // B + patch[i * 4 + 3] = 255; // A + } + auto patch_bytes = std::as_bytes(std::span(patch)); + img.update_region(1, 1, 2, 2, patch_bytes); + + // Verify the dirty region. + auto dirty = img.dirty_region(); + REQUIRE(dirty.has_value()); + CHECK(dirty->min(0) == 1); + CHECK(dirty->min(1) == 1); + CHECK(dirty->width() == 2); + CHECK(dirty->height() == 2); + CHECK_FALSE(img.is_full_dirty()); + + // Verify pixel at (1, 1) is red. + auto px = img.pixels(); + size_t offset_1_1 = (1 * w + 1) * 4; // row 1, col 1 + CHECK(static_cast(px[offset_1_1 + 0]) == 255); + CHECK(static_cast(px[offset_1_1 + 1]) == 0); + CHECK(static_cast(px[offset_1_1 + 2]) == 0); + CHECK(static_cast(px[offset_1_1 + 3]) == 255); + + // Verify pixel at (0, 0) is still black. + CHECK(static_cast(px[0]) == 0); + CHECK(static_cast(px[1]) == 0); + CHECK(static_cast(px[2]) == 0); + CHECK(static_cast(px[3]) == 0); +} + +TEST_CASE("ImageData update_region merges dirty rectangles", + "[scene_graph][image]") { + using namespace balsa::scene_graph; + + ImageData img; + uint32_t w = 8, h = 8; + std::vector rgba(w * h * 4, 0); + img.set_pixels_rgba8(w, h, rgba); + img.clear_dirty(); + + // First patch at (0, 0) size 2x2. + std::vector p1(2 * 2 * 4, 100); + img.update_region(0, 0, 2, 2, std::as_bytes(std::span(p1))); + + // Second patch at (4, 4) size 3x3. + std::vector p2(3 * 3 * 4, 200); + img.update_region(4, 4, 3, 3, std::as_bytes(std::span(p2))); + + // Dirty region should be the union bounding box: (0,0) to (7,7). + auto dirty = img.dirty_region(); + REQUIRE(dirty.has_value()); + CHECK(dirty->min(0) == 0); + CHECK(dirty->min(1) == 0); + CHECK(dirty->width() == 7); // max(0+2, 4+3) - min(0,4) = 7 - 0 + CHECK(dirty->height() == 7); +} + +TEST_CASE("ImageData clear_dirty resets tracking", "[scene_graph][image]") { + using namespace balsa::scene_graph; + + ImageData img; + std::vector rgba(4 * 4 * 4, 0); + img.set_pixels_rgba8(4, 4, rgba); + + REQUIRE(img.dirty_region().has_value()); + REQUIRE(img.is_full_dirty()); + + img.clear_dirty(); + CHECK_FALSE(img.dirty_region().has_value()); +} + +TEST_CASE("ImageData display parameters", "[scene_graph][image]") { + using namespace balsa::scene_graph; + + ImageData img; + + img.set_exposure(2.5f); + CHECK(img.exposure() == Catch::Approx(2.5f)); + + img.set_gamma(1.0f); + CHECK(img.gamma() == Catch::Approx(1.0f)); + + img.set_channel_mode(ImageData::ChannelMode::Red); + CHECK(img.channel_mode() == ImageData::ChannelMode::Red); + + img.set_channel_mode(ImageData::ChannelMode::Luminance); + CHECK(img.channel_mode() == ImageData::ChannelMode::Luminance); +} + +TEST_CASE("ImageData as Object feature", "[scene_graph][image]") { + using namespace balsa::scene_graph; + + Object obj("image_obj"); + auto &img = obj.emplace_feature(); + + ImageData *found = obj.find_feature(); + REQUIRE(found != nullptr); + CHECK(found == &img); + + std::vector rgba(2 * 2 * 4, 255); + img.set_pixels_rgba8(2, 2, rgba); + CHECK(found->has_pixels()); + CHECK(found->width() == 2); + CHECK(found->height() == 2); +} + +TEST_CASE("ImageData set_pixels throws on insufficient data", + "[scene_graph][image]") { + using namespace balsa::scene_graph; + + ImageData img; + // 4x4 RGBA8 needs 64 bytes, only provide 10. + std::vector too_small(10, 0); + CHECK_THROWS(img.set_pixels_rgba8(4, 4, too_small)); +} + +TEST_CASE("ImageData update_region throws on out-of-bounds", + "[scene_graph][image]") { + using namespace balsa::scene_graph; + + ImageData img; + std::vector rgba(4 * 4 * 4, 0); + img.set_pixels_rgba8(4, 4, rgba); + + // Region exceeds image bounds. + std::vector patch(3 * 3 * 4, 100); + CHECK_THROWS(img.update_region( + 2, 2, 3, 3, std::as_bytes(std::span(patch)))); +} + +TEST_CASE("ImageData update_region throws when no image set", + "[scene_graph][image]") { + using namespace balsa::scene_graph; + + ImageData img; + std::vector patch(4, 100); + CHECK_THROWS(img.update_region( + 0, 0, 1, 1, std::as_bytes(std::span(patch)))); +} + +// ── PPM I/O Tests ────────────────────────────────────────────────── + +TEST_CASE("PPM round-trip save and load", "[image_io]") { + using namespace balsa::visualization; + + uint32_t w = 3, h = 2; + // Create a known 3x2 RGBA image. + std::vector rgba(w * h * 4); + for (uint32_t y = 0; y < h; ++y) { + for (uint32_t x = 0; x < w; ++x) { + size_t i = (y * w + x) * 4; + rgba[i + 0] = static_cast(x * 80); // R + rgba[i + 1] = static_cast(y * 120); // G + rgba[i + 2] = static_cast((x + y) * 40); // B + rgba[i + 3] = 255; // A (ignored in PPM) + } + } + + std::string path = "/tmp/balsa_test_ppm_roundtrip.ppm"; + auto save_result = save_ppm(path, w, h, rgba.data()); + REQUIRE(save_result.has_value()); + + auto load_result = load_ppm(path); + REQUIRE(load_result.has_value()); + + const auto &img = load_result.value(); + CHECK(img.width == w); + CHECK(img.height == h); + REQUIRE(img.pixels.size() == w * h * 4); + + // Verify pixel data (RGB should match, alpha should be 255). + for (uint32_t y = 0; y < h; ++y) { + for (uint32_t x = 0; x < w; ++x) { + size_t i = (y * w + x) * 4; + CHECK(img.pixels[i + 0] == rgba[i + 0]); // R + CHECK(img.pixels[i + 1] == rgba[i + 1]); // G + CHECK(img.pixels[i + 2] == rgba[i + 2]); // B + CHECK(img.pixels[i + 3] == 255); // A + } + } + + // Cleanup. + std::remove(path.c_str()); +} + +TEST_CASE("PPM load nonexistent file", "[image_io]") { + using namespace balsa::visualization; + + auto result = load_ppm("/tmp/balsa_test_nonexistent_file.ppm"); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error() == ImageIOError::FileNotFound); +} + +TEST_CASE("PPM load invalid format", "[image_io]") { + using namespace balsa::visualization; + + std::string path = "/tmp/balsa_test_invalid_ppm.ppm"; + // Write something that is not a valid PPM. + { + std::FILE *f = std::fopen(path.c_str(), "wb"); + REQUIRE(f != nullptr); + std::fprintf(f, "NOT_PPM\n"); + std::fclose(f); + } + + auto result = load_ppm(path); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error() == ImageIOError::InvalidFormat); + + std::remove(path.c_str()); +} + +TEST_CASE("PPM error_string returns non-empty strings", "[image_io]") { + using namespace balsa::visualization; + + CHECK(!error_string(ImageIOError::FileNotFound).empty()); + CHECK(!error_string(ImageIOError::InvalidFormat).empty()); + CHECK(!error_string(ImageIOError::ReadError).empty()); + CHECK(!error_string(ImageIOError::WriteError).empty()); +} + TEST_CASE("Object rotate() post-multiplies quaternion", "[scene_graph][trs]") { using namespace balsa::scene_graph; diff --git a/visualization/tools/image_viewer_glfw.cpp b/visualization/tools/image_viewer_glfw.cpp new file mode 100644 index 0000000..d4736e6 --- /dev/null +++ b/visualization/tools/image_viewer_glfw.cpp @@ -0,0 +1,280 @@ +// image_viewer_glfw.cpp — GLFW + Vulkan image viewer with ImGui controls +// +// Usage: image_viewer_glfw [--input path/to/image.ppm] +// image_viewer_glfw path/to/image.ppm +// +// Loads a PPM image and displays it with tone-mapping controls +// (exposure, gamma, channel isolation) via an ImGui panel. +// +// Navigation: scroll to zoom, middle-drag to pan. +// The image is rendered as a fullscreen textured triangle with +// orthographic projection. + +#include +#include +#include +#include + +#if BALSA_HAS_CLI11 +#include +#endif +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace viz = balsa::visualization; +namespace vk_viz = viz::vulkan; + +// ── ImageViewerScene ──────────────────────────────────────────────── +// +// An ImageScene subclass that adds an ImGui overlay with image display +// controls and a main menu bar. + +class ImageViewerScene : public vk_viz::ImageScene { + public: + ImageViewerScene() { + // Dark background for image viewing. + set_clear_color(0.1f, 0.1f, 0.1f, 1.0f); + } + ~ImageViewerScene() override = default; + + auto init_imgui(vk_viz::Film &film, GLFWwindow *glfw_window) -> void { + _imgui.init(film, glfw_window); + } + + using OpenFileCallback = std::function; + auto set_open_file_callback(OpenFileCallback cb) -> void { + _open_file_cb = std::move(cb); + } + + auto draw(vk_viz::Film &film) -> void override { + // Draw the image first. + ImageScene::draw(film); + + // Then draw ImGui overlay on top. + if (_imgui.is_initialized()) { + _imgui.new_frame(); + draw_main_menu_bar(); + draw_open_file_dialog(); + vk_viz::imgui::draw_image_controls(*this, _panel_state); + _imgui.render(film); + } + } + + auto release_vulkan_resources() -> void override { + _imgui.shutdown(); + ImageScene::release_vulkan_resources(); + } + + private: + vk_viz::ImGuiIntegration _imgui; + vk_viz::imgui::ImagePanelState _panel_state; + OpenFileCallback _open_file_cb; + + bool _show_open_dialog = false; + char _path_buf[1024] = {}; + std::string _open_error; + + auto draw_main_menu_bar() -> void { + if (ImGui::BeginMainMenuBar()) { + if (ImGui::BeginMenu("File")) { + if (ImGui::MenuItem("Open...", "Ctrl+O")) { + _show_open_dialog = true; + _open_error.clear(); + } + ImGui::Separator(); + if (ImGui::MenuItem("Quit", "Ctrl+Q")) { std::exit(0); } + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("View")) { + ImGui::MenuItem( + "Image Controls", nullptr, &_panel_state.show_controls); + ImGui::EndMenu(); + } + ImGui::EndMainMenuBar(); + } + } + + auto draw_open_file_dialog() -> void { + if (!_show_open_dialog) return; + + ImGui::SetNextWindowSize(ImVec2(500, 0), ImGuiCond_FirstUseEver); + if (ImGui::Begin("Open Image File", &_show_open_dialog)) { + ImGui::Text("Enter PPM file path:"); + bool enter_pressed = + ImGui::InputText("##path", + _path_buf, + sizeof(_path_buf), + ImGuiInputTextFlags_EnterReturnsTrue); + + ImGui::SameLine(); + bool load_clicked = ImGui::Button("Load"); + + if (enter_pressed || load_clicked) { + std::filesystem::path p(_path_buf); + if (std::filesystem::exists(p)) { + if (_open_file_cb) { _open_file_cb(p); } + _show_open_dialog = false; + _open_error.clear(); + } else { + _open_error = "File not found: " + p.string(); + } + } + + if (!_open_error.empty()) { + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1, 0.3f, 0.3f, 1)); + ImGui::TextWrapped("%s", _open_error.c_str()); + ImGui::PopStyleColor(); + } + } + ImGui::End(); + } +}; + +// ── ImageViewerWindow ─────────────────────────────────────────────── +// +// Subclass of glfw::vulkan::Window that: +// 1. Overrides dispatch_mouse / dispatch_key to let ImGui consume +// events before they reach the zoom/pan handler. +// 2. Owns the ImageViewerScene and provides load_image(). +// 3. Handles scroll-to-zoom and middle-drag-to-pan. + +class ImageViewerWindow : public viz::glfw::vulkan::Window { + public: + ImageViewerWindow(const std::string_view &title, int width, int height) + : viz::glfw::vulkan::Window(title, width, height) { + _scene = std::make_shared(); + + // Initialize ImGui. + _scene->init_imgui(film(), glfw_window()); + + // Wire up the "Open" dialog callback. + _scene->set_open_file_callback( + [this](const std::filesystem::path &p) { load_image(p); }); + + set_scene(_scene); + } + + ~ImageViewerWindow() override = default; + + auto load_image(const std::filesystem::path &path) -> void { + spdlog::info("Loading image: {}", path.string()); + + auto result = viz::load_ppm(path.string()); + if (!result) { + spdlog::error("Failed to load image: {} ({})", + path.string(), + viz::error_string(result.error())); + return; + } + + auto &img = *result; + spdlog::info(" Image size: {} x {}", img.width, img.height); + + _scene->set_image_rgba8( + img.width, img.height, std::span(img.pixels)); + _scene->fit_to_window(); + } + + protected: + auto dispatch_mouse(const viz::MouseEvent &e) -> void override { + if (ImGui::GetCurrentContext() && ImGui::GetIO().WantCaptureMouse) { + return; + } + + // Scroll to zoom. + if (e.type == viz::MouseEvent::Type::Scroll) { + float zoom = _scene->zoom(); + zoom *= (e.scroll_y > 0) ? 1.1f : (1.0f / 1.1f); + _scene->set_zoom(zoom); + return; + } + + // Middle-drag to pan. + if (e.type == viz::MouseEvent::Type::Move && _middle_dragging) { + auto fb = framebuffer_size(); + float dx = static_cast(e.x - _last_x) + / static_cast(fb[0]) * 2.0f; + float dy = static_cast(e.y - _last_y) + / static_cast(fb[1]) * 2.0f; + _scene->set_pan(_scene->pan_x() + dx, _scene->pan_y() - dy); + _last_x = e.x; + _last_y = e.y; + return; + } + + if (e.type == viz::MouseEvent::Type::Press + && e.button == 2) { // 2 = middle button + _middle_dragging = true; + _last_x = e.x; + _last_y = e.y; + return; + } + + if (e.type == viz::MouseEvent::Type::Release + && e.button == 2) { // 2 = middle button + _middle_dragging = false; + return; + } + + viz::glfw::vulkan::Window::dispatch_mouse(e); + } + + auto dispatch_key(const viz::KeyEvent &e) -> void override { + if (ImGui::GetCurrentContext() && ImGui::GetIO().WantCaptureKeyboard) { + return; + } + viz::glfw::vulkan::Window::dispatch_key(e); + } + + private: + std::shared_ptr _scene; + bool _middle_dragging = false; + double _last_x = 0.0; + double _last_y = 0.0; +}; + +// ── main ───────────────────────────────────────────────────────────── + +auto main(int argc, char *argv[]) -> int { + spdlog::set_level(spdlog::level::info); + + std::string input_path; +#if BALSA_HAS_CLI11 + CLI::App app{"Balsa Image Viewer (GLFW + Vulkan + ImGui)", + "image_viewer_glfw"}; + + app.add_option("input", input_path, "PPM image file to load") + ->check(CLI::ExistingFile); + + CLI11_PARSE(app, argc, argv); +#else + if (argc > 1) { input_path = argv[1]; } +#endif + + glfwInit(); + + try { + ImageViewerWindow window("Balsa Image Viewer (GLFW)", 1280, 960); + + if (!input_path.empty()) { + window.load_image(std::filesystem::path(input_path)); + } + + return window.exec(); + + } catch (const std::exception &e) { + std::cerr << "Fatal: " << e.what() << std::endl; + glfwTerminate(); + return EXIT_FAILURE; + } + + glfwTerminate(); + return 0; +} diff --git a/visualization/tools/mesh_viewer_glfw.cpp b/visualization/tools/mesh_viewer_glfw.cpp index c762e3c..18594c1 100644 --- a/visualization/tools/mesh_viewer_glfw.cpp +++ b/visualization/tools/mesh_viewer_glfw.cpp @@ -2,10 +2,12 @@ // // Usage: mesh_viewer_glfw [--input path/to/model.obj] // mesh_viewer_glfw path/to/model.obj +// mesh_viewer_glfw path/to/model.msh // -// Loads an OBJ mesh, renders it with Phong shading, and provides an -// ImGui panel for tweaking render state (shading model, color source, -// colormaps, lighting, wireframe, etc.). +// Loads an OBJ or MSH mesh via quiver I/O, renders it with Phong +// shading, and provides an ImGui panel for tweaking render state +// (shading model, color source, colormaps, lighting, wireframe, etc.) +// and selecting which attribute is bound to each visualization role. // // BVH bounding volume visualization is available via right-click // context menu on mesh objects in the scene graph panel. @@ -28,19 +30,17 @@ #include #include +#include +#include #include +#include #include -#include #include +#include #include -#include -#include -#include - -#include -#include -#include +#include +#include namespace viz = balsa::visualization; namespace vk_viz = viz::vulkan; @@ -66,7 +66,9 @@ class MeshViewerScene : public vk_viz::MeshScene { // Set a callback invoked when the user confirms a file path in the // ImGui "Open" dialog. using OpenFileCallback = std::function; - void set_open_file_callback(OpenFileCallback cb) { _open_file_cb = std::move(cb); } + void set_open_file_callback(OpenFileCallback cb) { + _open_file_cb = std::move(cb); + } void draw(vk_viz::Film &film) override { // Draw mesh geometry first @@ -105,15 +107,17 @@ class MeshViewerScene : public vk_viz::MeshScene { _open_error.clear(); } ImGui::Separator(); - if (ImGui::MenuItem("Quit", "Ctrl+Q")) { - std::exit(0); - } + if (ImGui::MenuItem("Quit", "Ctrl+Q")) { std::exit(0); } ImGui::EndMenu(); } if (ImGui::BeginMenu("View")) { - ImGui::MenuItem("Scene Graph", nullptr, &_panel_state.show_scene_panel); - ImGui::MenuItem("Properties", nullptr, &_panel_state.show_property_panel); - ImGui::MenuItem("Scene Lighting", nullptr, &_panel_state.show_lighting_panel); + ImGui::MenuItem( + "Scene Graph", nullptr, &_panel_state.show_scene_panel); + ImGui::MenuItem( + "Properties", nullptr, &_panel_state.show_property_panel); + ImGui::MenuItem("Scene Lighting", + nullptr, + &_panel_state.show_lighting_panel); ImGui::EndMenu(); } ImGui::EndMainMenuBar(); @@ -126,8 +130,11 @@ class MeshViewerScene : public vk_viz::MeshScene { ImGui::SetNextWindowSize(ImVec2(500, 0), ImGuiCond_FirstUseEver); if (ImGui::Begin("Open Mesh File", &_show_open_dialog)) { ImGui::Text("Enter OBJ file path:"); - bool enter_pressed = ImGui::InputText( - "##path", _path_buf, sizeof(_path_buf), ImGuiInputTextFlags_EnterReturnsTrue); + bool enter_pressed = + ImGui::InputText("##path", + _path_buf, + sizeof(_path_buf), + ImGuiInputTextFlags_EnterReturnsTrue); ImGui::SameLine(); bool load_clicked = ImGui::Button("Load"); @@ -135,9 +142,7 @@ class MeshViewerScene : public vk_viz::MeshScene { if (enter_pressed || load_clicked) { std::filesystem::path p(_path_buf); if (std::filesystem::exists(p)) { - if (_open_file_cb) { - _open_file_cb(p); - } + if (_open_file_cb) { _open_file_cb(p); } _show_open_dialog = false; _open_error.clear(); } else { @@ -172,9 +177,8 @@ class MeshViewerWindow : public viz::glfw::vulkan::Window { _scene->init_imgui(film(), glfw_window()); // Wire up the "Open" dialog callback - _scene->set_open_file_callback([this](const std::filesystem::path &p) { - load_obj(p); - }); + _scene->set_open_file_callback( + [this](const std::filesystem::path &p) { load_mesh(p); }); // Set up orbit camera _camera = std::make_shared(_scene.get()); @@ -184,7 +188,9 @@ class MeshViewerWindow : public viz::glfw::vulkan::Window { // Set initial projection auto fb = framebuffer_size(); - float aspect = (fb[1] > 0) ? static_cast(fb[0]) / static_cast(fb[1]) : 1.0f; + float aspect = + (fb[1] > 0) ? static_cast(fb[0]) / static_cast(fb[1]) + : 1.0f; constexpr float pi = 3.14159265358979323846f; _scene->set_perspective(45.0f * pi / 180.0f, aspect, 0.01f, 100.0f); @@ -193,91 +199,93 @@ class MeshViewerWindow : public viz::glfw::vulkan::Window { ~MeshViewerWindow() override = default; - void load_obj(const std::filesystem::path &path) { - spdlog::info("Loading OBJ: {}", path.string()); - - auto obj = balsa::geometry::triangle_mesh::read_objF(path); - const auto &pos = obj.position; - const auto &nrm = obj.normal; + void load_mesh(const std::filesystem::path &path) { + spdlog::info("Loading mesh: {}", path.string()); - if (pos.vertices.extent(1) == 0) { - spdlog::error("OBJ file has no vertices: {}", path.string()); + // Use quiver's format-dispatching reader (OBJ, MSH, ...). + auto result = quiver::io::read_mesh(path); + if (!result) { + spdlog::error("Failed to load mesh: {}", path.string()); return; } + auto mesh = std::move(*result); - std::size_t n_verts = pos.vertices.extent(1); - std::size_t n_tris = pos.triangles.extent(1); - - spdlog::info(" {} vertices, {} triangles", n_verts, n_tris); - - // Normalize to unit bounding box centered at origin - balsa::ColVectors V = pos.vertices; - auto bb = balsa::geometry::bounding_box(V); - auto bb_range = bb.range(); - float range = static_cast(::zipper::utils::maxCoeff(bb_range)); - if (range < 1e-8f) range = 1.0f; - auto bb_center = (bb.min() + bb.max()) / 2.0; - for (::zipper::index_type j = 0; j < V.extent(1); ++j) { - auto col = V.col(j); - for (::zipper::index_type i = 0; i < 3; ++i) { - col(i) = static_cast((static_cast(col(i)) - bb_center(i)) / range); - } - } + spdlog::info(" Mesh dimension: {}", + static_cast(mesh->dimension())); + + // Compute bounding box from the position attribute for Object + // transform normalization. Position attribute types may be + // array, array, array, etc. + // We iterate the discovered attributes after set_mesh() to find + // the position binding and compute the AABB. - // Add a mesh Object to the scene graph + // Add a mesh Object to the scene graph. auto &mesh_obj = _scene->add_mesh(path.filename().string()); auto *mesh_data = mesh_obj.find_feature(); - // Convert positions from ColVectors (SOA) to vector (AOS) - std::vector positions(n_verts); - for (std::size_t j = 0; j < n_verts; ++j) { - positions[j](0) = V(0, j); - positions[j](1) = V(1, j); - positions[j](2) = V(2, j); - } - mesh_data->set_positions(positions); - - // Convert and set normals if available - if (nrm.vertices.extent(1) == pos.vertices.extent(1)) { - std::vector normals(n_verts); - for (std::size_t j = 0; j < n_verts; ++j) { - normals[j](0) = nrm.vertices(0, j); - normals[j](1) = nrm.vertices(1, j); - normals[j](2) = nrm.vertices(2, j); - } - mesh_data->set_normals(normals); - } + // set_mesh() enumerates attributes, auto-assigns roles by + // convention name (vertex_positions → position, vertex_normals + // → normal), builds skeletons, and extracts topology indices. + mesh_data->set_mesh(mesh); + // Apply constraints: auto-selects shading/normal_source based // on whether the mesh actually has normal data. mesh_data->render_state().constrain(mesh_data->has_normals()); - // Convert triangle indices (size_t -> uint32_t) - std::vector tri_indices; - if (n_tris > 0) { - tri_indices.resize(n_tris * 3); - for (std::size_t j = 0; j < n_tris; ++j) { - tri_indices[j * 3 + 0] = static_cast(pos.triangles(0, j)); - tri_indices[j * 3 + 1] = static_cast(pos.triangles(1, j)); - tri_indices[j * 3 + 2] = static_cast(pos.triangles(2, j)); - } - mesh_data->set_triangle_indices(tri_indices); - } + // Compute bounding box from the position binding and set Object + // transform to center and normalize the mesh. The source data + // stays pristine — normalization is applied via the Object's + // local transform. + if (mesh_data->has_positions()) { + const auto &pos_bind = mesh_data->position_binding(); + const auto *fdata = static_cast(pos_bind.raw_data()); + std::size_t n = pos_bind.size(); + uint8_t comp = pos_bind.component_count; + + if (fdata && n > 0 && comp >= 1) { + // Compute AABB in up to 3 dimensions. + float bbmin[3] = {1e30f, 1e30f, 1e30f}; + float bbmax[3] = {-1e30f, -1e30f, -1e30f}; + for (std::size_t i = 0; i < n; ++i) { + for (uint8_t c = 0; c < std::min(comp, uint8_t(3)); ++c) { + float v = fdata[i * comp + c]; + bbmin[c] = std::min(bbmin[c], v); + bbmax[c] = std::max(bbmax[c], v); + } + } + // For missing dimensions, leave at 0. + for (uint8_t c = comp; c < 3; ++c) { + bbmin[c] = 0.0f; + bbmax[c] = 0.0f; + } - // If the OBJ has explicit edge data from 'l' lines, set those - // as explicit edges. Otherwise, MeshData auto-derives edges - // from the triangle topology (built in set_triangle_indices). - if (pos.edges.extent(1) > 0) { - std::size_t n_edges = pos.edges.extent(1); - std::vector edge_indices(n_edges * 2); - for (std::size_t j = 0; j < n_edges; ++j) { - edge_indices[j * 2 + 0] = static_cast(pos.edges(0, j)); - edge_indices[j * 2 + 1] = static_cast(pos.edges(1, j)); + // Compute center and range. + sg::Vec3f center; + float range = 0.0f; + for (int c = 0; c < 3; ++c) { + center(c) = (bbmin[c] + bbmax[c]) * 0.5f; + range = std::max(range, bbmax[c] - bbmin[c]); + } + if (range < 1e-8f) range = 1.0f; + + // Apply normalization via Object transform. + sg::Vec3f neg_center; + neg_center(0) = -center(0); + neg_center(1) = -center(1); + neg_center(2) = -center(2); + mesh_obj.set_translation(neg_center); + sg::Vec3f uniform_scale; + float inv_range = 1.0f / range; + uniform_scale(0) = inv_range; + uniform_scale(1) = inv_range; + uniform_scale(2) = inv_range; + mesh_obj.set_scale_factors(uniform_scale); } - mesh_data->set_edge_indices(edge_indices); } - // If the mesh has edges but no triangles, default to wireframe - if (!mesh_data->has_triangle_indices() && mesh_data->has_edge_indices()) { + // If the mesh has edges but no triangles, default to wireframe. + if (!mesh_data->has_triangle_indices() + && mesh_data->has_edge_indices()) { mesh_data->render_state().layers.solid.enabled = false; mesh_data->render_state().layers.wireframe.enabled = true; } @@ -316,16 +324,15 @@ int main(int argc, char *argv[]) { std::string input_path; #if BALSA_HAS_CLI11 - CLI::App app{ "Balsa Mesh Viewer (GLFW + Vulkan + ImGui)", "mesh_viewer_glfw" }; + CLI::App app{"Balsa Mesh Viewer (GLFW + Vulkan + ImGui)", + "mesh_viewer_glfw"}; app.add_option("input", input_path, "OBJ file to load") - ->check(CLI::ExistingFile); + ->check(CLI::ExistingFile); CLI11_PARSE(app, argc, argv); #else - if (argc > 1) { - input_path = argv[1]; - } + if (argc > 1) { input_path = argv[1]; } #endif glfwInit(); @@ -334,7 +341,7 @@ int main(int argc, char *argv[]) { MeshViewerWindow window("Balsa Mesh Viewer (GLFW)", 1280, 960); if (!input_path.empty()) { - window.load_obj(std::filesystem::path(input_path)); + window.load_mesh(std::filesystem::path(input_path)); } return window.exec(); diff --git a/visualization/tools/mesh_viewer_qt.cpp b/visualization/tools/mesh_viewer_qt.cpp index 12a27fd..67373a1 100644 --- a/visualization/tools/mesh_viewer_qt.cpp +++ b/visualization/tools/mesh_viewer_qt.cpp @@ -1,11 +1,11 @@ // mesh_viewer_qt.cpp — Qt + Vulkan mesh viewer with native Qt controls // -// Usage: mesh_viewer_qt [--input path/to/model.obj] -// mesh_viewer_qt path/to/model.obj +// Usage: mesh_viewer_qt [--input path/to/model.{obj,msh}] +// mesh_viewer_qt path/to/model.{obj,msh} // -// Embeds a QVulkanWindow in a QMainWindow with a MeshControlsWidget -// dock panel. Supports loading OBJ files via File > Open or via -// command-line argument. +// Loads an OBJ or MSH mesh via quiver I/O, renders it with Phong +// shading, and provides Qt dock panels for scene graph navigation +// and mesh property editing. // // Camera: left-drag orbit, right-drag pan, scroll zoom. @@ -28,33 +28,32 @@ #include #include -#include #include +#include // Qt defines 'emit' as a macro, which conflicts with TBB's // tbb::profiling::emit(). Undefine it before pulling in headers // that transitively include TBB (via quiver), then restore it. #undef emit +#include +#include +#include +#include #include -#include #include +#include #include -#include -#include -#include -#include -#include -#include +#include +#include // Restore Qt's emit macro (expands to nothing, but needed for readability). #define emit -#include - namespace viz = balsa::visualization; namespace vk_viz = viz::vulkan; +namespace sg = balsa::scene_graph; // ── MeshViewerMainWindow ──────────────────────────────────────────── // @@ -64,13 +63,15 @@ namespace vk_viz = viz::vulkan; class MeshViewerMainWindow : public QMainWindow { public: - MeshViewerMainWindow(QVulkanInstance *inst, const std::filesystem::path &initial_obj) + MeshViewerMainWindow(QVulkanInstance *inst, + const std::filesystem::path &initial_obj) : QMainWindow(nullptr) { setWindowTitle("Balsa Mesh Viewer (Qt)"); resize(1280, 800); // ── Create the Vulkan window ───────────────────────────────── - _vk_window = new viz::qt::vulkan::Window("Balsa Mesh Viewer (Qt)", 900, 800); + _vk_window = + new viz::qt::vulkan::Window("Balsa Mesh Viewer (Qt)", 900, 800); _vk_window->setVulkanInstance(inst); // Wrap QVulkanWindow in a widget for embedding @@ -88,7 +89,8 @@ class MeshViewerMainWindow : public QMainWindow { // Initial projection constexpr float pi = 3.14159265358979323846f; - _scene->set_perspective(45.0f * pi / 180.0f, 900.0f / 800.0f, 0.01f, 100.0f); + _scene->set_perspective( + 45.0f * pi / 180.0f, 900.0f / 800.0f, 0.01f, 100.0f); // ── Create the scene graph outliner dock ───────────────────── _outliner = new viz::qt::SceneGraphWidget(); @@ -96,7 +98,8 @@ class MeshViewerMainWindow : public QMainWindow { _outliner_dock = new QDockWidget("Scene Graph", this); _outliner_dock->setWidget(_outliner); - _outliner_dock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea); + _outliner_dock->setAllowedAreas(Qt::LeftDockWidgetArea + | Qt::RightDockWidgetArea); addDockWidget(Qt::RightDockWidgetArea, _outliner_dock); // ── Create the mesh properties dock ────────────────────────── @@ -105,7 +108,8 @@ class MeshViewerMainWindow : public QMainWindow { _controls_dock = new QDockWidget("Mesh Properties", this); _controls_dock->setWidget(_controls); - _controls_dock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea); + _controls_dock->setAllowedAreas(Qt::LeftDockWidgetArea + | Qt::RightDockWidgetArea); addDockWidget(Qt::RightDockWidgetArea, _controls_dock); // Stack docks vertically: outliner on top, properties below @@ -113,17 +117,29 @@ class MeshViewerMainWindow : public QMainWindow { splitDockWidget(_outliner_dock, _controls_dock, Qt::Vertical); // ── Wire outliner selection → properties panel ─────────────── - connect(_outliner, &viz::qt::SceneGraphWidget::object_selected, _controls, &viz::qt::MeshControlsWidget::set_selected_object); + connect(_outliner, + &viz::qt::SceneGraphWidget::object_selected, + _controls, + &viz::qt::MeshControlsWidget::set_selected_object); // Re-render when controls or outliner change - connect(_controls, &viz::qt::MeshControlsWidget::scene_changed, _vk_window, [this]() { _vk_window->requestUpdate(); }); - connect(_outliner, &viz::qt::SceneGraphWidget::scene_changed, _vk_window, [this]() { _vk_window->requestUpdate(); }); + connect(_controls, + &viz::qt::MeshControlsWidget::scene_changed, + _vk_window, + [this]() { _vk_window->requestUpdate(); }); + connect(_outliner, + &viz::qt::SceneGraphWidget::scene_changed, + _vk_window, + [this]() { _vk_window->requestUpdate(); }); // Camera activation from the outliner - connect(_outliner, &viz::qt::SceneGraphWidget::camera_activated, this, [this](balsa::scene_graph::Object *cam_obj) { - _scene->set_active_camera(cam_obj); - _vk_window->requestUpdate(); - }); + connect(_outliner, + &viz::qt::SceneGraphWidget::camera_activated, + this, + [this](balsa::scene_graph::Object *cam_obj) { + _scene->set_active_camera(cam_obj); + _vk_window->requestUpdate(); + }); // ── Create menus ───────────────────────────────────────────── create_menus(); @@ -148,7 +164,7 @@ class MeshViewerMainWindow : public QMainWindow { // timer to give it one more event-loop pass. if (!_initial_obj.empty() && !_loaded) { QTimer::singleShot(100, this, [this]() { - load_obj(_initial_obj); + load_mesh(_initial_obj); _loaded = true; }); } @@ -172,9 +188,13 @@ class MeshViewerMainWindow : public QMainWindow { open_action->setShortcuts(QKeySequence::Open); connect(open_action, &QAction::triggered, this, [this]() { QString path = QFileDialog::getOpenFileName( - this, tr("Open Mesh"), QString(), tr("OBJ Files (*.obj);;All Files (*)")); + this, + tr("Open Mesh"), + QString(), + tr("Mesh Files (*.obj *.msh);;OBJ Files (*.obj);;MSH Files " + "(*.msh);;All Files (*)")); if (!path.isEmpty()) { - load_obj(std::filesystem::path(path.toStdString())); + load_mesh(std::filesystem::path(path.toStdString())); } }); @@ -195,93 +215,88 @@ class MeshViewerMainWindow : public QMainWindow { // always present inside MeshControlsWidget.) } - void load_obj(const std::filesystem::path &path) { - spdlog::info("Loading OBJ: {}", path.string()); + void load_mesh(const std::filesystem::path &path) { + spdlog::info("Loading mesh: {}", path.string()); - auto obj = balsa::geometry::triangle_mesh::read_objF(path); - const auto &pos = obj.position; - const auto &nrm = obj.normal; - - if (pos.vertices.extent(1) == 0) { - spdlog::error("OBJ file has no vertices: {}", path.string()); + // Use quiver's format-dispatching reader (OBJ, MSH, ...). + auto result = quiver::io::read_mesh(path); + if (!result) { + spdlog::error("Failed to load mesh: {}", path.string()); return; } + auto mesh = std::move(*result); - spdlog::info(" {} vertices, {} triangles", - pos.vertices.extent(1), - pos.triangles.extent(1)); - - // Normalize to unit bounding box centered at origin - balsa::ColVectors V = pos.vertices; - auto bb = balsa::geometry::bounding_box(V); - auto bb_range = bb.range(); - float range = static_cast(::zipper::utils::maxCoeff(bb_range)); - if (range < 1e-8f) range = 1.0f; - auto bb_center = (bb.min() + bb.max()) / 2.0; - for (::zipper::index_type j = 0; j < V.extent(1); ++j) { - auto col = V.col(j); - for (::zipper::index_type i = 0; i < 3; ++i) { - col(i) = static_cast((static_cast(col(i)) - bb_center(i)) / range); - } - } + spdlog::info(" Mesh dimension: {}", + static_cast(mesh->dimension())); - // Add a mesh Object to the scene graph + // Add a mesh Object to the scene graph. auto &mesh_obj = _scene->add_mesh(path.filename().string()); - auto *mesh_data = mesh_obj.find_feature(); - - // Convert positions from ColVectors (SOA row-major) to - // vector (AOS) - std::size_t n_verts = V.extent(1); - { - std::vector positions(n_verts); - for (std::size_t j = 0; j < n_verts; ++j) { - positions[j](0) = V(0, j); - positions[j](1) = V(1, j); - positions[j](2) = V(2, j); - } - mesh_data->set_positions(positions); - } + auto *mesh_data = mesh_obj.find_feature(); + + // set_mesh() enumerates attributes, auto-assigns roles by + // convention name (vertex_positions -> position, vertex_normals + // -> normal), builds skeletons, and extracts topology indices. + mesh_data->set_mesh(mesh); - // Convert and set normals if available - if (nrm.vertices.extent(1) == pos.vertices.extent(1)) { - std::vector normals(n_verts); - for (std::size_t j = 0; j < n_verts; ++j) { - normals[j](0) = nrm.vertices(0, j); - normals[j](1) = nrm.vertices(1, j); - normals[j](2) = nrm.vertices(2, j); - } - mesh_data->set_normals(normals); - } // Apply constraints: auto-selects shading/normal_source based // on whether the mesh actually has normal data. mesh_data->render_state().constrain(mesh_data->has_normals()); - // Convert triangle indices (size_t -> uint32_t) - if (pos.triangles.extent(1) > 0) { - std::size_t n_tris = pos.triangles.extent(1); - std::vector tri_indices(n_tris * 3); - for (std::size_t j = 0; j < n_tris; ++j) { - tri_indices[j * 3 + 0] = static_cast(pos.triangles(0, j)); - tri_indices[j * 3 + 1] = static_cast(pos.triangles(1, j)); - tri_indices[j * 3 + 2] = static_cast(pos.triangles(2, j)); - } - mesh_data->set_triangle_indices(tri_indices); - } - - // Convert edge indices - if (pos.edges.extent(1) > 0) { - std::size_t n_edges = pos.edges.extent(1); - std::vector edge_indices(n_edges * 2); - for (std::size_t j = 0; j < n_edges; ++j) { - edge_indices[j * 2 + 0] = static_cast(pos.edges(0, j)); - edge_indices[j * 2 + 1] = static_cast(pos.edges(1, j)); + // Compute bounding box from the position binding and set Object + // transform to center and normalize the mesh. The source data + // stays pristine -- normalization is applied via the Object's + // local transform. + if (mesh_data->has_positions()) { + const auto &pos_bind = mesh_data->position_binding(); + const auto *fdata = static_cast(pos_bind.raw_data()); + std::size_t n = pos_bind.size(); + uint8_t comp = pos_bind.component_count; + + if (fdata && n > 0 && comp >= 1) { + // Compute AABB in up to 3 dimensions. + float bbmin[3] = {1e30f, 1e30f, 1e30f}; + float bbmax[3] = {-1e30f, -1e30f, -1e30f}; + for (std::size_t i = 0; i < n; ++i) { + for (uint8_t c = 0; c < std::min(comp, uint8_t(3)); ++c) { + float v = fdata[i * comp + c]; + bbmin[c] = std::min(bbmin[c], v); + bbmax[c] = std::max(bbmax[c], v); + } + } + // For missing dimensions, leave at 0. + for (uint8_t c = comp; c < 3; ++c) { + bbmin[c] = 0.0f; + bbmax[c] = 0.0f; + } + + // Compute center and range. + sg::Vec3f center; + float range = 0.0f; + for (int c = 0; c < 3; ++c) { + center(c) = (bbmin[c] + bbmax[c]) * 0.5f; + range = std::max(range, bbmax[c] - bbmin[c]); + } + if (range < 1e-8f) range = 1.0f; + + // Apply normalization via Object transform. + sg::Vec3f neg_center; + neg_center(0) = -center(0); + neg_center(1) = -center(1); + neg_center(2) = -center(2); + mesh_obj.set_translation(neg_center); + sg::Vec3f uniform_scale; + float inv_range = 1.0f / range; + uniform_scale(0) = inv_range; + uniform_scale(1) = inv_range; + uniform_scale(2) = inv_range; + mesh_obj.set_scale_factors(uniform_scale); } - mesh_data->set_edge_indices(edge_indices); } // If the mesh has edges but no triangles, default to wireframe // so that edge-only meshes are visible immediately. - if (!mesh_data->has_triangle_indices() && mesh_data->has_edge_indices()) { + if (!mesh_data->has_triangle_indices() + && mesh_data->has_edge_indices()) { mesh_data->render_state().layers.solid.enabled = false; mesh_data->render_state().layers.wireframe.enabled = true; } @@ -308,19 +323,17 @@ int main(int argc, char *argv[]) { // ── CLI argument parsing (before QApplication consumes args) ───── std::string input_path; #if BALSA_HAS_CLI11 - CLI::App cli{ "Balsa Mesh Viewer (Qt + Vulkan)", "mesh_viewer_qt" }; + CLI::App cli{"Balsa Mesh Viewer (Qt + Vulkan)", "mesh_viewer_qt"}; - cli.add_option("input", input_path, "OBJ file to load") - ->check(CLI::ExistingFile); + cli.add_option("input", input_path, "Mesh file to load (OBJ, MSH)") + ->check(CLI::ExistingFile); // Allow Qt-specific flags (e.g. -platform) to pass through. cli.allow_extras(true); CLI11_PARSE(cli, argc, argv); #else // Fallback: treat first argument as input file - if (argc > 1) { - input_path = argv[1]; - } + if (argc > 1) { input_path = argv[1]; } #endif QApplication app(argc, argv); @@ -328,13 +341,11 @@ int main(int argc, char *argv[]) { balsa::qt::activateSpdlogOutput(); std::filesystem::path obj_path; - if (!input_path.empty()) { - obj_path = input_path; - } + if (!input_path.empty()) { obj_path = input_path; } // QVulkanInstance must outlive the QVulkanWindow QVulkanInstance inst; - inst.setLayers({ "VK_LAYER_KHRONOS_validation" }); + inst.setLayers({"VK_LAYER_KHRONOS_validation"}); if (!inst.create()) { spdlog::error("Failed to create QVulkanInstance"); return 1; diff --git a/visualization/tools/meson.build b/visualization/tools/meson.build index 6da0dea..959dc91 100644 --- a/visualization/tools/meson.build +++ b/visualization/tools/meson.build @@ -22,3 +22,14 @@ endif executable('mesh_viewer_qt', 'mesh_viewer_qt.cpp', dependencies: mesh_viewer_qt_deps, cpp_args: mesh_viewer_qt_args) + +# ── image_viewer_glfw ──────────────────────────────────────────────── +image_viewer_glfw_deps = [visualization_dep] +image_viewer_glfw_args = [] +if cli11_dep.found() + image_viewer_glfw_deps += cli11_dep + image_viewer_glfw_args += '-DBALSA_HAS_CLI11=1' +endif +executable('image_viewer_glfw', 'image_viewer_glfw.cpp', + dependencies: image_viewer_glfw_deps, + cpp_args: image_viewer_glfw_args)