diff --git a/tsd/apps/interactive/CMakeLists.txt b/tsd/apps/interactive/CMakeLists.txt index b8533e498..adb17fe61 100644 --- a/tsd/apps/interactive/CMakeLists.txt +++ b/tsd/apps/interactive/CMakeLists.txt @@ -4,6 +4,7 @@ add_subdirectory(dataTreeEditor) add_subdirectory(demos) add_subdirectory(multiDeviceViewer) +add_subdirectory(scivisStudio) add_subdirectory(viewer) if (TSD_USE_MPI) add_subdirectory(mpiViewer) diff --git a/tsd/apps/interactive/mpiViewer/DistributedViewport.cpp b/tsd/apps/interactive/mpiViewer/DistributedViewport.cpp index 7ceaf179d..d4440c8db 100644 --- a/tsd/apps/interactive/mpiViewer/DistributedViewport.cpp +++ b/tsd/apps/interactive/mpiViewer/DistributedViewport.cpp @@ -425,4 +425,10 @@ int DistributedViewport::windowFlags() const return ImGuiWindowFlags_MenuBar; } +int DistributedViewport::pushStyle() +{ + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4.f, 4.f)); + return 1; +} + } // namespace tsd::mpi_viewer diff --git a/tsd/apps/interactive/mpiViewer/DistributedViewport.h b/tsd/apps/interactive/mpiViewer/DistributedViewport.h index 4e00a4f37..5044fb79b 100644 --- a/tsd/apps/interactive/mpiViewer/DistributedViewport.h +++ b/tsd/apps/interactive/mpiViewer/DistributedViewport.h @@ -47,6 +47,7 @@ struct DistributedViewport : public tsd::ui::imgui::Window void ui_timeControls(); int windowFlags() const override; + int pushStyle() override; // Data ///////////////////////////////////////////////////////////////////// diff --git a/tsd/apps/interactive/scivisStudio/Application.cpp b/tsd/apps/interactive/scivisStudio/Application.cpp new file mode 100644 index 000000000..176634fe6 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/Application.cpp @@ -0,0 +1,692 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "Application.h" + +#include "DefaultLayout.h" +#include "RenderShot.h" +#include "modals/AddFileAnimationDatasetDialog.h" +#include "modals/AddStaticDatasetDialog.h" +#include "modals/ProjectLocationDialog.h" +#include "windows/CameraRigEditor.h" +#include "windows/DatasetEditor.h" +#include "windows/LightRigEditor.h" +#include "windows/ProjectWindow.h" +#include "windows/ShotEditor.h" + +#include "tsd/core/Logging.hpp" +#include "tsd/ui/imgui/windows/LayerTree.h" +#include "tsd/ui/imgui/windows/Log.h" +#include "tsd/ui/imgui/windows/ObjectEditor.h" +#include "tsd/ui/imgui/windows/TransferFunctionEditor.h" +#include "tsd/ui/imgui/windows/Viewport.h" + +#include "imgui.h" + +#include +#include +#include +#include +#include +#include + +namespace tsd::scivis_studio { + + +using TSDApplication = tsd::ui::imgui::Application; +namespace tsd_ui = tsd::ui::imgui; + +namespace { + +constexpr std::size_t MAX_RECENT_PROJECTS = 10; + +std::filesystem::path studioConfigDirectory() +{ +#ifdef _WIN32 + if (const char *appData = std::getenv("APPDATA"); appData != nullptr) + return std::filesystem::path(appData) / "tsd" / "studio"; +#else + if (const char *home = std::getenv("HOME"); home != nullptr) + return std::filesystem::path(home) / ".config" / "tsd" / "studio"; +#endif + + return std::filesystem::path("studio"); +} + +std::filesystem::path normalizedAbsolutePath(const std::filesystem::path &path) +{ + std::error_code ec; + auto absolute = std::filesystem::absolute(path, ec); + if (ec) + absolute = path; + return absolute.lexically_normal(); +} + +bool pathsReferToSameProject( + const std::filesystem::path &a, const std::filesystem::path &b) +{ + std::error_code ec; + if (std::filesystem::exists(a, ec) && !ec && std::filesystem::exists(b, ec) + && !ec) { + const bool same = std::filesystem::equivalent(a, b, ec); + if (!ec && same) + return true; + } + + return normalizedAbsolutePath(a) == normalizedAbsolutePath(b); +} + +bool renderConfirmationModal(ConfirmationModalState &modal) +{ + if (!modal.visible) + return false; + + ImGuiIO &io = ImGui::GetIO(); + ImGui::SetNextWindowPos( + ImVec2(io.DisplaySize.x * 0.5f, io.DisplaySize.y * 0.5f), + ImGuiCond_Always, + ImVec2(0.5f, 0.5f)); + + ImGui::OpenPopup(modal.title.c_str()); + if (ImGui::BeginPopupModal(modal.title.c_str(), + &modal.visible, + ImGuiWindowFlags_AlwaysAutoResize)) { + if (modal.minWidth > 0.f) + ImGui::Dummy(ImVec2(modal.minWidth, 0.f)); + if (!modal.message.empty()) + ImGui::TextUnformatted(modal.message.c_str()); + ImGui::Spacing(); + + if (ImGui::Button(modal.cancelLabel.c_str())) { + modal.visible = false; + if (modal.onCancel) + modal.onCancel(); + } + + ImGui::SameLine(); + + if (ImGui::Button(modal.confirmLabel.c_str())) { + modal.visible = false; + if (modal.onConfirm) + modal.onConfirm(); + } + + ImGui::EndPopup(); + } + + return modal.visible; +} + +} // namespace + +Application::Application(int argc, const char **argv) + : TSDApplication(argc, argv), m_projectContext(appContext()) +{ + if (!appContext()->commandLine.stateFile.empty()) + m_initialProjectDirectory = appContext()->commandLine.stateFile; + appContext()->commandLine.stateFile.clear(); + appContext()->commandLine.loadedFromStateFile = false; +} + +Application::~Application() = default; + +ProjectContext &Application::projectContext() +{ + return m_projectContext; +} + +const ProjectContext &Application::projectContext() const +{ + return m_projectContext; +} + +tsd::ui::imgui::WindowArray Application::setupWindows() +{ + auto windows = TSDApplication::setupWindows(); + loadRecentProjects(); + + auto *ctx = appContext(); + m_viewport = new tsd_ui::Viewport(this, &ctx->view.manipulator, "Viewport"); + auto *projectWindow = new ProjectWindow(this, &m_projectContext); + auto *datasetEditor = new DatasetEditor(this, &m_projectContext); + auto *lightRigEditor = new LightRigEditor(this, &m_projectContext); + auto *shotEditor = + new ShotEditor(this, &m_projectContext, [this]() { renderActiveShot(); }); + auto *cameraRigEditor = new CameraRigEditor(this, &m_projectContext); + auto *objectEditor = new tsd_ui::ObjectEditor(this); + m_layerTree = new tsd_ui::LayerTree(this); + m_transferFunctionEditor = new tsd_ui::TransferFunctionEditor(this); + auto *log = new tsd_ui::Log(this); + + windows.emplace_back(projectWindow); + windows.emplace_back(datasetEditor); + windows.emplace_back(lightRigEditor); + windows.emplace_back(shotEditor); + windows.emplace_back(cameraRigEditor); + windows.emplace_back(m_viewport); + windows.emplace_back(objectEditor); + windows.emplace_back(m_layerTree); + windows.emplace_back(m_transferFunctionEditor); + windows.emplace_back(log); + + setWindowArray(windows); + + m_layerTree->hide(); + m_transferFunctionEditor->hide(); + + m_projectLocationDialog = std::make_unique(this); + m_addStaticDatasetDialog = + std::make_unique(this, &m_projectContext); + m_addFileAnimationDatasetDialog = + std::make_unique(this, &m_projectContext); + + if (!m_initialProjectDirectory.empty()) { + if (!openProject(m_initialProjectDirectory)) { + m_projectContext.createUnsavedProject(); + m_keepBlankProjectCleanAfterViewportSetup = true; + m_viewport->setLibraryToDefault(); + } + } else { + m_projectContext.createUnsavedProject(); + m_keepBlankProjectCleanAfterViewportSetup = true; + m_viewport->setLibraryToDefault(); + } + + ctx->tsd.sceneLoadComplete = true; + + return windows; +} + +void Application::teardown() +{ + TSDApplication::teardown(); +} + +void Application::saveWindowSettings(tsd::core::DataNode &node) +{ + node.reset(); + for (auto *w : m_windows) + w->saveSettings(node[w->name()]); +} + +void Application::loadWindowSettings(tsd::core::DataNode &node) +{ + for (auto *w : m_windows) + w->loadSettings(node[w->name()]); +} + +std::string Application::saveLayout() const +{ + return ImGui::SaveIniSettingsToMemory(); +} + +void Application::loadLayout(const std::string &layout) +{ + if (!layout.empty()) + ImGui::LoadIniSettingsFromMemory(layout.c_str()); +} + +bool Application::saveProject() +{ + auto &project = m_projectContext.project(); + if (!project.isSaved()) { + showProjectLocationDialogForSaveAs(); + return false; + } + + return saveProjectAs(project.projectDirectory); +} + +bool Application::saveProjectAs(const std::filesystem::path &directory) +{ + tsd::core::DataTree scratch; + auto &root = scratch.root(); + saveWindowSettings(root["windows"]); + saveApplicationSettings(root); + + std::string error; + const bool ok = m_projectContext.saveProject(directory, + root.child("windows"), + saveLayout(), + root.child("settings"), + &error); + if (!ok) + tsd::core::logError("[SciVisStudio] Save failed: %s", error.c_str()); + else + addRecentProject(directory); + return ok; +} + +bool Application::openProject(const std::filesystem::path &directory) +{ + if (m_viewport) + m_viewport->releaseSceneReferences(); + + tsd::core::DataTree scratch; + std::string layout; + std::string error; + const bool ok = m_projectContext.openProject(directory, + &scratch.root()["windows"], + &layout, + &scratch.root()["settings"], + &error); + if (!ok) { + tsd::core::logError("[SciVisStudio] Open failed: %s", error.c_str()); + if (m_viewport) + m_viewport->setLibraryToDefault(); + return false; + } + + loadWindowSettings(scratch.root()["windows"]); + loadLayout(layout); + loadApplicationSettings(scratch.root()); + addRecentProject(directory); + return true; +} + +void Application::newProject() +{ + if (m_viewport) + m_viewport->releaseSceneReferences(); + + m_projectContext.createUnsavedProject(); + m_keepBlankProjectCleanAfterViewportSetup = true; + if (m_viewport) + m_viewport->setLibraryToDefault(); +} + +void Application::requestDirtyAction(PendingDirtyAction action) +{ + if (!m_projectContext.project().dirty) { + m_pendingDirtyAction = action; + continueDirtyAction(); + return; + } + + m_pendingDirtyAction = action; + m_confirmationModal.visible = true; + m_confirmationModal.title = "Discard Unsaved Changes"; + m_confirmationModal.message = "The current project has unsaved changes."; + m_confirmationModal.cancelLabel = "Cancel"; + m_confirmationModal.confirmLabel = "Discard and Continue"; + m_confirmationModal.minWidth = 0.f; + m_confirmationModal.onCancel = [this]() { + m_pendingDirtyAction = PendingDirtyAction::None; + m_pendingProjectDirectory.clear(); + }; + m_confirmationModal.onConfirm = [this]() { continueDirtyAction(); }; +} + +void Application::requestOpenRecentProject( + const std::filesystem::path &directory) +{ + m_pendingProjectDirectory = directory; + requestDirtyAction(PendingDirtyAction::OpenRecentProject); +} + +void Application::continueDirtyAction() +{ + const auto action = m_pendingDirtyAction; + m_pendingDirtyAction = PendingDirtyAction::None; + + if (action == PendingDirtyAction::NewProject) + newProject(); + else if (action == PendingDirtyAction::OpenProject) + showProjectLocationDialogForOpen(); + else if (action == PendingDirtyAction::OpenRecentProject) { + const auto directory = m_pendingProjectDirectory; + m_pendingProjectDirectory.clear(); + if (!openProject(directory)) + removeRecentProject(directory); + } +} + +std::filesystem::path Application::recentProjectsFile() const +{ + return studioConfigDirectory() / "recent_projects.txt"; +} + +void Application::loadRecentProjects() +{ + m_recentProjects.clear(); + + const auto filename = recentProjectsFile(); + if (!std::filesystem::exists(filename)) + return; + + std::ifstream in(filename); + if (!in) { + tsd::core::logWarning( + "[SciVisStudio] Failed to read recent projects file '%s'", + filename.string().c_str()); + return; + } + + std::string line; + while (std::getline(in, line)) { + if (line.empty()) + continue; + + const auto path = normalizedAbsolutePath(line); + const auto duplicate = std::any_of(m_recentProjects.begin(), + m_recentProjects.end(), + [&](const auto &entry) { + return pathsReferToSameProject(entry, path); + }); + if (!duplicate) + m_recentProjects.push_back(path); + + if (m_recentProjects.size() >= MAX_RECENT_PROJECTS) + break; + } +} + +void Application::saveRecentProjects() const +{ + const auto filename = recentProjectsFile(); + const auto directory = filename.parent_path(); + + try { + if (!directory.empty()) + std::filesystem::create_directories(directory); + } catch (const std::exception &e) { + tsd::core::logWarning( + "[SciVisStudio] Failed to create recent projects directory '%s': %s", + directory.string().c_str(), + e.what()); + return; + } + + std::ofstream out(filename, std::ios::trunc); + if (!out) { + tsd::core::logWarning( + "[SciVisStudio] Failed to write recent projects file '%s'", + filename.string().c_str()); + return; + } + + for (const auto &project : m_recentProjects) + out << project.string() << '\n'; +} + +void Application::addRecentProject(const std::filesystem::path &directory) +{ + const auto path = normalizedAbsolutePath(directory); + + m_recentProjects.erase(std::remove_if(m_recentProjects.begin(), + m_recentProjects.end(), + [&](const auto &entry) { + return pathsReferToSameProject(entry, path); + }), + m_recentProjects.end()); + m_recentProjects.insert(m_recentProjects.begin(), path); + + if (m_recentProjects.size() > MAX_RECENT_PROJECTS) + m_recentProjects.resize(MAX_RECENT_PROJECTS); + + saveRecentProjects(); +} + +void Application::removeRecentProject(const std::filesystem::path &directory) +{ + const auto oldSize = m_recentProjects.size(); + m_recentProjects.erase(std::remove_if(m_recentProjects.begin(), + m_recentProjects.end(), + [&](const auto &entry) { + return pathsReferToSameProject(entry, directory); + }), + m_recentProjects.end()); + + if (m_recentProjects.size() != oldSize) + saveRecentProjects(); +} + +void Application::clearRecentProjects() +{ + m_recentProjects.clear(); + saveRecentProjects(); +} + +void Application::uiRecentProjectsMenu() +{ + std::filesystem::path selectedProject; + bool clearRequested = false; + + if (ImGui::BeginMenu("Recent")) { + if (m_recentProjects.empty()) + ImGui::TextDisabled("No recent projects"); + + for (const auto &project : m_recentProjects) { + const auto label = project.string(); + if (ImGui::MenuItem(label.c_str())) + selectedProject = project; + } + + ImGui::Separator(); + if (ImGui::MenuItem("Clear Recent")) + clearRequested = true; + ImGui::EndMenu(); + } + + if (!selectedProject.empty()) + requestOpenRecentProject(selectedProject); + else if (clearRequested) + clearRecentProjects(); +} + +void Application::showAddStaticDatasetDialog() +{ + m_addStaticDatasetDialog->show(); +} + +void Application::showAddFileAnimationDatasetDialog() +{ + m_addFileAnimationDatasetDialog->show(); +} + +void Application::showProjectLocationDialogForOpen() +{ + m_projectLocationDialog->configure(ProjectLocationMode::OpenProject, + [this]( + const std::filesystem::path &directory) { openProject(directory); }); + m_projectLocationDialog->show(); +} + +void Application::showProjectLocationDialogForSaveAs() +{ + m_projectLocationDialog->configure(ProjectLocationMode::SaveProjectAs, + [this](const std::filesystem::path &directory) { + saveProjectAs(directory); + }); + m_projectLocationDialog->show(); +} + +void Application::renderActiveShot() +{ + if (!m_projectContext.project().isSaved()) { + tsd::core::logWarning( + "[SciVisStudio] Save the project before rendering a shot"); + showProjectLocationDialogForSaveAs(); + return; + } + + if (m_viewport) { + m_viewport->setRenderingEnabled(false); + m_viewportRenderingDisabledForShotRender = true; + } + + showTaskModalWithCancel( + [this](const std::atomic_bool &cancelRequested) { + RenderShotProgress progress; + progress.onFrame = [&](int, int) { return !cancelRequested.load(); }; + renderActiveShotToFrames(m_projectContext, &progress); + }, + "Rendering Active Shot..."); + + if (!m_taskModal && m_viewportRenderingDisabledForShotRender) { + if (m_viewport) + m_viewport->setRenderingEnabled(true); + m_viewportRenderingDisabledForShotRender = false; + } +} + +void Application::saveDefaultLayoutFile() const +{ + const std::string layout = ImGui::SaveIniSettingsToMemory(); + + std::ofstream out(DEFAULT_LAYOUT_FILE, std::ios::binary | std::ios::trunc); + if (!out) { + tsd::core::logError( + "[SciVisStudio] Failed to open default layout file '%s'", + DEFAULT_LAYOUT_FILE); + return; + } + + out << layout; + if (layout.empty() || layout.back() != '\n') + out << '\n'; + + if (!out) { + tsd::core::logError( + "[SciVisStudio] Failed to write default layout file '%s'", + DEFAULT_LAYOUT_FILE); + return; + } + + tsd::core::logStatus( + "[SciVisStudio] Saved default layout file '%s'", DEFAULT_LAYOUT_FILE); +} + +void Application::uiFrameStart() +{ + const ImGuiIO &io = ImGui::GetIO(); + auto &animMgr = appContext()->tsd.animationMgr; + animMgr.tick(io.DeltaTime); + if (auto *shot = project::activeShot(m_projectContext.project())) + shot->playing = animMgr.isPlaying(); + + if (ImGui::BeginMainMenuBar()) { + uiMainMenuBar(); + ImGui::EndMainMenuBar(); + } + + bool modalActive = false; + if (m_taskModal && m_taskModal->visible()) { + m_taskModal->renderUI(); + modalActive = true; + } + + if (m_viewportRenderingDisabledForShotRender + && (!m_taskModal || !m_taskModal->visible())) { + if (m_viewport) + m_viewport->setRenderingEnabled(true); + m_viewportRenderingDisabledForShotRender = false; + } + + if (m_projectLocationDialog && m_projectLocationDialog->visible()) { + m_projectLocationDialog->renderUI(); + modalActive = true; + } + + modalActive = renderConfirmationModal(m_confirmationModal) || modalActive; + + if (m_addStaticDatasetDialog && m_addStaticDatasetDialog->visible()) { + m_addStaticDatasetDialog->renderUI(); + modalActive = true; + } + + if (m_addFileAnimationDatasetDialog + && m_addFileAnimationDatasetDialog->visible()) { + m_addFileAnimationDatasetDialog->renderUI(); + modalActive = true; + } + + if (!io.WantTextInput && ImGui::IsKeyPressed(ImGuiKey_Space)) { + if (auto *shot = project::activeShot(m_projectContext.project())) { + animMgr.togglePlay(); + shot->playing = animMgr.isPlaying(); + } + } + + if (ImGui::IsKeyChordPressed(ImGuiMod_Ctrl | ImGuiKey_S)) + saveProject(); + + if (!modalActive && ImGui::IsKeyChordPressed(ImGuiKey_Escape)) + appContext()->clearSelected(); + + if (m_keepBlankProjectCleanAfterViewportSetup + && (!m_taskModal || !m_taskModal->visible())) { + m_projectContext.project().markClean(); + m_keepBlankProjectCleanAfterViewportSetup = false; + } +} + +void Application::uiMainMenuBar() +{ + if (ImGui::BeginMenu("Project")) { + if (ImGui::MenuItem("New")) + requestDirtyAction(PendingDirtyAction::NewProject); + if (ImGui::MenuItem("Open ...")) + requestDirtyAction(PendingDirtyAction::OpenProject); + ImGui::Separator(); + uiRecentProjectsMenu(); + ImGui::Separator(); + if (ImGui::MenuItem("Save", "Ctrl+S")) + saveProject(); + if (ImGui::MenuItem("Save As...")) + showProjectLocationDialogForSaveAs(); + ImGui::Separator(); + if (ImGui::MenuItem("Quit")) + std::exit(0); + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("Studio")) { + if (ImGui::BeginMenu("Add Dataset")) { + if (ImGui::MenuItem("Static...")) + showAddStaticDatasetDialog(); + if (ImGui::MenuItem("File Animation...")) + showAddFileAnimationDatasetDialog(); + ImGui::EndMenu(); + } + if (ImGui::MenuItem("Add Shot")) + m_projectContext.addShot(); + if (ImGui::MenuItem("Render Active Shot...")) + renderActiveShot(); + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("View")) { + for (auto *w : m_windows) { + ImGui::PushID(w); + ImGui::Checkbox(w->name(), w->visiblePtr()); + ImGui::PopID(); + } + ImGui::Separator(); + if (ImGui::MenuItem("Save Default Layout File")) { + m_confirmationModal.visible = true; + m_confirmationModal.title = "Update Default Layout"; + m_confirmationModal.message = "Are you sure?"; + m_confirmationModal.cancelLabel = "No"; + m_confirmationModal.confirmLabel = "Yes"; + m_confirmationModal.minWidth = 700.f; + m_confirmationModal.onCancel = {}; + m_confirmationModal.onConfirm = [this]() { saveDefaultLayoutFile(); }; + } + if (ImGui::MenuItem("Reset Layout")) + ImGui::LoadIniSettingsFromMemory(getDefaultLayout()); + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("Tools")) { + ImGui::TextDisabled("No phase-one tools"); + ImGui::EndMenu(); + } +} + +const char *Application::getDefaultLayout() const +{ + return DEFAULT_LAYOUT; +} + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/Application.h b/tsd/apps/interactive/scivisStudio/Application.h new file mode 100644 index 000000000..0c91eb715 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/Application.h @@ -0,0 +1,116 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "ProjectContext.h" + +#include "tsd/ui/imgui/Application.h" + +#include +#include +#include +#include +#include + +namespace tsd::ui::imgui { +struct LayerTree; +struct Log; +struct ObjectEditor; +struct TransferFunctionEditor; +struct Viewport; +} // namespace tsd::ui::imgui + +namespace tsd::scivis_studio { + +struct AddStaticDatasetDialog; +struct AddFileAnimationDatasetDialog; +struct CameraRigEditor; +struct DatasetEditor; +struct LightRigEditor; +struct ProjectLocationDialog; +struct ProjectWindow; +struct ShotEditor; + +struct ConfirmationModalState +{ + bool visible{false}; + std::string title; + std::string message; + std::string cancelLabel; + std::string confirmLabel; + float minWidth{0.f}; + std::function onCancel; + std::function onConfirm; +}; + +class Application : public tsd::ui::imgui::Application +{ + public: + Application(int argc = 0, const char **argv = nullptr); + ~Application() override; + + ProjectContext &projectContext(); + const ProjectContext &projectContext() const; + + void showAddStaticDatasetDialog(); + void showAddFileAnimationDatasetDialog(); + void showProjectLocationDialogForOpen(); + void showProjectLocationDialogForSaveAs(); + void renderActiveShot(); + + protected: + tsd::ui::imgui::WindowArray setupWindows() override; + void uiFrameStart() override; + void teardown() override; + void uiMainMenuBar() override; + const char *getDefaultLayout() const override; + + private: + enum class PendingDirtyAction + { + None, + NewProject, + OpenProject, + OpenRecentProject + }; + + bool saveProject(); + bool saveProjectAs(const std::filesystem::path &directory); + bool openProject(const std::filesystem::path &directory); + void newProject(); + void saveDefaultLayoutFile() const; + void saveWindowSettings(tsd::core::DataNode &node); + void loadWindowSettings(tsd::core::DataNode &node); + std::string saveLayout() const; + void loadLayout(const std::string &layout); + void requestDirtyAction(PendingDirtyAction action); + void requestOpenRecentProject(const std::filesystem::path &directory); + void continueDirtyAction(); + void loadRecentProjects(); + void saveRecentProjects() const; + void addRecentProject(const std::filesystem::path &directory); + void removeRecentProject(const std::filesystem::path &directory); + void clearRecentProjects(); + void uiRecentProjectsMenu(); + std::filesystem::path recentProjectsFile() const; + + ProjectContext m_projectContext; + std::filesystem::path m_initialProjectDirectory; + std::filesystem::path m_pendingProjectDirectory; + std::vector m_recentProjects; + PendingDirtyAction m_pendingDirtyAction{PendingDirtyAction::None}; + bool m_viewportRenderingDisabledForShotRender{false}; + bool m_keepBlankProjectCleanAfterViewportSetup{false}; + + tsd::ui::imgui::Viewport *m_viewport{nullptr}; + tsd::ui::imgui::LayerTree *m_layerTree{nullptr}; + tsd::ui::imgui::TransferFunctionEditor *m_transferFunctionEditor{nullptr}; + + std::unique_ptr m_projectLocationDialog; + std::unique_ptr m_addStaticDatasetDialog; + std::unique_ptr m_addFileAnimationDatasetDialog; + ConfirmationModalState m_confirmationModal; +}; + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/CMakeLists.txt b/tsd/apps/interactive/scivisStudio/CMakeLists.txt new file mode 100644 index 000000000..e103346d8 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/CMakeLists.txt @@ -0,0 +1,75 @@ +## Copyright 2026 NVIDIA Corporation +## SPDX-License-Identifier: Apache-2.0 + +add_library(tsd_scivis_studio_model STATIC + Dataset.cpp + Project.cpp + ProjectContext.cpp + ProjectSerialization.cpp + RenderShot.cpp + RenderShotCLI.cpp + Shot.cpp + ShotCameraRig.cpp +) + +target_include_directories(tsd_scivis_studio_model +PUBLIC + ${CMAKE_CURRENT_LIST_DIR} +) + +target_link_libraries(tsd_scivis_studio_model +PUBLIC + tsd_app + tsd_core + tsd_io + tsd_rendering + tsd_scene +) + +set(SCIVIS_STUDIO_DEFAULT_LAYOUT_FILE + "${CMAKE_CURRENT_LIST_DIR}/default_ui_layout.txt") + +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + "${SCIVIS_STUDIO_DEFAULT_LAYOUT_FILE}") + +file(READ + "${SCIVIS_STUDIO_DEFAULT_LAYOUT_FILE}" + SCIVIS_STUDIO_DEFAULT_LAYOUT) + +configure_file( + DefaultLayout.h.in + "${CMAKE_CURRENT_BINARY_DIR}/DefaultLayout.h" + @ONLY) + +add_executable(scivisStudio + Application.cpp + modals/AddFileAnimationDatasetDialog.cpp + modals/AddStaticDatasetDialog.cpp + modals/ProjectLocationDialog.cpp + scivisStudio.cpp + windows/CameraRigEditor.cpp + windows/DatasetEditor.cpp + windows/LightRigEditor.cpp + windows/ProjectWindow.cpp + windows/ShotEditor.cpp +) + +target_include_directories(scivisStudio +PRIVATE + "${CMAKE_CURRENT_BINARY_DIR}" +) + +target_link_libraries(scivisStudio +PRIVATE + tsd_scivis_studio_model + tsd_ui_imgui +) + +add_executable(scivisStudioRenderShot + scivisStudioRenderShot.cpp +) + +target_link_libraries(scivisStudioRenderShot +PRIVATE + tsd_scivis_studio_model +) diff --git a/tsd/apps/interactive/scivisStudio/Dataset.cpp b/tsd/apps/interactive/scivisStudio/Dataset.cpp new file mode 100644 index 000000000..46e51d2e6 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/Dataset.cpp @@ -0,0 +1,56 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "Dataset.h" + +namespace tsd::scivis_studio::dataset { + +const char *toString(DatasetSourceKind kind) +{ + switch (kind) { + case DatasetSourceKind::Static: + return "Static"; + case DatasetSourceKind::TimeSeries: + return "TimeSeries"; + case DatasetSourceKind::Live: + return "Live"; + } + return "Static"; +} + +const char *toString(DatasetStatus status) +{ + switch (status) { + case DatasetStatus::Available: + return "Available"; + case DatasetStatus::Missing: + return "Missing"; + case DatasetStatus::Importing: + return "Importing"; + case DatasetStatus::ImportFailed: + return "ImportFailed"; + } + return "Missing"; +} + +DatasetSourceKind sourceKindFromString(const std::string &s) +{ + if (s == "TimeSeries") + return DatasetSourceKind::TimeSeries; + if (s == "Live") + return DatasetSourceKind::Live; + return DatasetSourceKind::Static; +} + +DatasetStatus statusFromString(const std::string &s) +{ + if (s == "Available") + return DatasetStatus::Available; + if (s == "Importing") + return DatasetStatus::Importing; + if (s == "ImportFailed") + return DatasetStatus::ImportFailed; + return DatasetStatus::Missing; +} + +} // namespace tsd::scivis_studio::dataset diff --git a/tsd/apps/interactive/scivisStudio/Dataset.h b/tsd/apps/interactive/scivisStudio/Dataset.h new file mode 100644 index 000000000..df50db79b --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/Dataset.h @@ -0,0 +1,85 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/core/ObjectPool.hpp" + +#include + +#include +#include +#include + +namespace tsd::scivis_studio { + +using DatasetID = std::string; +using ShotID = std::string; +using ColorMapID = std::string; +using LightRigID = std::string; + +struct SceneNodeRef +{ + std::string layerName; + size_t nodeIndex{TSD_INVALID_INDEX}; +}; + +struct SceneObjectRef +{ + anari::DataType type{ANARI_UNKNOWN}; + size_t objectIndex{TSD_INVALID_INDEX}; +}; + +enum class DatasetSourceKind +{ + Static, + TimeSeries, + Live +}; + +enum class DatasetStatus +{ + Available, + Missing, + Importing, + ImportFailed +}; + +struct DatasetSourceMetadata +{ + std::string absolutePath; + std::string projectRelativePath; + uint64_t fileSize{0}; + int64_t modifiedTime{0}; +}; + +struct DatasetSourceFile +{ + std::string absolutePath; + std::string projectRelativePath; + uint64_t fileSize{0}; + int64_t modifiedTime{0}; +}; + +struct Dataset +{ + DatasetID id; + std::string name; + DatasetSourceKind sourceKind{DatasetSourceKind::Static}; + std::string importerType{"NONE"}; + DatasetSourceMetadata source; + DatasetStatus status{DatasetStatus::Missing}; + SceneNodeRef rootNode; + std::vector sourceFiles; +}; + +namespace dataset { + +const char *toString(DatasetSourceKind kind); +const char *toString(DatasetStatus status); +DatasetSourceKind sourceKindFromString(const std::string &s); +DatasetStatus statusFromString(const std::string &s); + +} // namespace dataset + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/DefaultLayout.h.in b/tsd/apps/interactive/scivisStudio/DefaultLayout.h.in new file mode 100644 index 000000000..31065e63e --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/DefaultLayout.h.in @@ -0,0 +1,12 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +namespace tsd::scivis_studio { + +inline constexpr const char *DEFAULT_LAYOUT = R"layout(@SCIVIS_STUDIO_DEFAULT_LAYOUT@)layout"; +inline constexpr const char *DEFAULT_LAYOUT_FILE = + R"layout_path(@SCIVIS_STUDIO_DEFAULT_LAYOUT_FILE@)layout_path"; + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/Project.cpp b/tsd/apps/interactive/scivisStudio/Project.cpp new file mode 100644 index 000000000..7d6cdb75c --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/Project.cpp @@ -0,0 +1,120 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "Project.h" + +#include +#include +#include + +namespace tsd::scivis_studio { + +bool Project::isSaved() const +{ + return !projectDirectory.empty(); +} + +void Project::markDirty() +{ + dirty = true; +} + +void Project::markClean() +{ + dirty = false; +} + +namespace project { + +std::string makeGeneratedId(const char *prefix, size_t ordinal) +{ + std::ostringstream ss; + ss << prefix << '_' << std::setfill('0') << std::setw(4) << ordinal; + return ss.str(); +} + +DatasetID nextDatasetId(const Project &project) +{ + return makeGeneratedId("dataset", project.datasets.size() + 1); +} + +ShotID nextShotId(const Project &project) +{ + return makeGeneratedId("shot", project.shots.size() + 1); +} + +ColorMapID nextColorMapId(const Project &project) +{ + return makeGeneratedId("colorMap", project.colorMaps.size() + 1); +} + +LightRigID nextLightRigId(const Project &project) +{ + return makeGeneratedId("lightRig", project.lightRigs.size() + 1); +} + +Dataset *findDataset(Project &project, const DatasetID &id) +{ + auto itr = std::find_if(project.datasets.begin(), + project.datasets.end(), + [&](const Dataset &d) { return d.id == id; }); + return itr == project.datasets.end() ? nullptr : &*itr; +} + +const Dataset *findDataset(const Project &project, const DatasetID &id) +{ + auto itr = std::find_if(project.datasets.begin(), + project.datasets.end(), + [&](const Dataset &d) { return d.id == id; }); + return itr == project.datasets.end() ? nullptr : &*itr; +} + +Shot *findShot(Project &project, const ShotID &id) +{ + auto itr = std::find_if(project.shots.begin(), + project.shots.end(), + [&](const Shot &s) { return s.id == id; }); + return itr == project.shots.end() ? nullptr : &*itr; +} + +const Shot *findShot(const Project &project, const ShotID &id) +{ + auto itr = std::find_if(project.shots.begin(), + project.shots.end(), + [&](const Shot &s) { return s.id == id; }); + return itr == project.shots.end() ? nullptr : &*itr; +} + +LightRig *findLightRig(Project &project, const LightRigID &id) +{ + auto itr = std::find_if(project.lightRigs.begin(), + project.lightRigs.end(), + [&](const LightRig &r) { return r.id == id; }); + return itr == project.lightRigs.end() ? nullptr : &*itr; +} + +const LightRig *findLightRig(const Project &project, const LightRigID &id) +{ + auto itr = std::find_if(project.lightRigs.begin(), + project.lightRigs.end(), + [&](const LightRig &r) { return r.id == id; }); + return itr == project.lightRigs.end() ? nullptr : &*itr; +} + +Shot *activeShot(Project &project) +{ + if (auto *shot = findShot(project, project.activeShotId)) + return shot; + return project.shots.empty() ? nullptr : &project.shots.front(); +} + +const Shot *activeShot(const Project &project) +{ + if (auto *shot = findShot(project, project.activeShotId)) + return shot; + return project.shots.empty() ? nullptr : &project.shots.front(); +} + +} // namespace project + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/Project.h b/tsd/apps/interactive/scivisStudio/Project.h new file mode 100644 index 000000000..ca72bf056 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/Project.h @@ -0,0 +1,63 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "Dataset.h" +#include "Shot.h" + +#include +#include +#include + +namespace tsd::scivis_studio { + +struct ColorMapRecord +{ + ColorMapID id; + std::string name; +}; + +struct LightRig +{ + LightRigID id; + std::string name; + SceneNodeRef rootNode; +}; + +struct Project +{ + std::string name{"Untitled"}; + std::filesystem::path projectDirectory; + std::vector datasets; + std::vector shots; + std::vector lightRigs; + std::vector colorMaps; + ShotID activeShotId; + bool dirty{false}; + + bool isSaved() const; + void markDirty(); + void markClean(); +}; + +namespace project { + +std::string makeGeneratedId(const char *prefix, size_t ordinal); +DatasetID nextDatasetId(const Project &project); +ShotID nextShotId(const Project &project); +ColorMapID nextColorMapId(const Project &project); +LightRigID nextLightRigId(const Project &project); + +Dataset *findDataset(Project &project, const DatasetID &id); +const Dataset *findDataset(const Project &project, const DatasetID &id); +Shot *findShot(Project &project, const ShotID &id); +const Shot *findShot(const Project &project, const ShotID &id); +LightRig *findLightRig(Project &project, const LightRigID &id); +const LightRig *findLightRig(const Project &project, const LightRigID &id); +Shot *activeShot(Project &project); +const Shot *activeShot(const Project &project); + +} // namespace project + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/ProjectContext.cpp b/tsd/apps/interactive/scivisStudio/ProjectContext.cpp new file mode 100644 index 000000000..ce01d3afc --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/ProjectContext.cpp @@ -0,0 +1,1023 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "ProjectContext.h" + +#include "ProjectSerialization.h" + +#include "tsd/core/DataTreeMetadata.hpp" +#include "tsd/core/Logging.hpp" +#include "tsd/io/serialization.hpp" +#include "tsd/rendering/view/ManipulatorToTSD.hpp" +#include "tsd/scene/objects/Camera.hpp" +#include "tsd/scene/objects/Light.hpp" + +#include +#include +#include +#include + +namespace tsd::scivis_studio { + + +static tsd::scene::LayerNodeRef findDirectChild( + tsd::scene::LayerNodeRef parent, const std::string &name) +{ + if (!parent) + return {}; + + auto child = parent->next(); + while (child && child != parent) { + if ((*child)->name() == name) + return child; + child = child->sibling(); + } + + return {}; +} + +static bool hasChildNodes(tsd::scene::LayerNodeRef parent) +{ + if (!parent) + return false; + + auto child = parent->next(); + return child && child != parent; +} + +ProjectContext::ProjectContext(tsd::app::Context *ctx) : m_ctx(ctx) +{ + installAnimationManagerCallback(); +} + +void ProjectContext::setAppContext(tsd::app::Context *ctx) +{ + m_ctx = ctx; + installAnimationManagerCallback(); +} + +tsd::app::Context *ProjectContext::appContext() const +{ + return m_ctx; +} + +Project &ProjectContext::project() +{ + return m_project; +} + +const Project &ProjectContext::project() const +{ + return m_project; +} + +void ProjectContext::resetScene() +{ + if (!m_ctx) + return; + m_ctx->clearSelected(); + m_ctx->tsd.animationMgr.removeAllAnimations(); + m_ctx->tsd.scene.removeAllObjects(); + m_ctx->tsd.scene.defaultMaterial(); + m_ctx->tsd.scene.defaultCamera(); +} + +void ProjectContext::installAnimationManagerCallback() +{ + if (!m_ctx) + return; + + m_ctx->tsd.animationMgr.setTimeChangedCallback( + [this](float) { updateActiveShotFromAnimationTime(); }); +} + +tsd::scene::LayerNodeRef ProjectContext::ensureChild( + tsd::scene::LayerNodeRef parent, const char *name) +{ + if (auto found = findDirectChild(parent, name)) + return found; + + return m_ctx->tsd.scene.insertChildNode(parent, name); +} + +tsd::scene::LayerNodeRef ProjectContext::ensureStudioRoot() +{ + auto *layer = m_ctx->tsd.scene.addLayer("studio"); + return layer ? layer->root() : tsd::scene::LayerNodeRef{}; +} + +tsd::scene::LayerNodeRef ProjectContext::ensureDatasetsRoot() +{ + return ensureChild(ensureStudioRoot(), "datasets"); +} + +tsd::scene::LayerNodeRef ProjectContext::ensureShotsRoot() +{ + return ensureChild(ensureStudioRoot(), "shots"); +} + +tsd::scene::LayerNodeRef ProjectContext::ensureLightRigsRoot() +{ + return ensureChild(ensureStudioRoot(), "lightRigs"); +} + +SceneNodeRef ProjectContext::refFor( + const std::string &layerName, tsd::scene::LayerNodeRef ref) const +{ + return {layerName, ref ? ref.index() : TSD_INVALID_INDEX}; +} + +tsd::scene::LayerNodeRef ProjectContext::resolve(const SceneNodeRef &ref) const +{ + if (!m_ctx || ref.layerName.empty() || ref.nodeIndex == TSD_INVALID_INDEX) + return {}; + + auto *layer = m_ctx->tsd.scene.layer(ref.layerName.c_str()); + return layer ? layer->at(ref.nodeIndex) : tsd::scene::LayerNodeRef{}; +} + +tsd::scene::Object *ProjectContext::resolve(const SceneObjectRef &ref) const +{ + if (!m_ctx || ref.type == ANARI_UNKNOWN + || ref.objectIndex == TSD_INVALID_INDEX) + return nullptr; + return m_ctx->tsd.scene.getObject(ref.type, ref.objectIndex); +} + +tsd::scene::LayerNodeRef ProjectContext::resolveDatasetRoot(Dataset &dataset) +{ + if (!m_ctx) + return {}; + + auto *layer = m_ctx->tsd.scene.layer("studio"); + if (layer) { + auto datasetsRoot = findDirectChild(layer->root(), "datasets"); + auto datasetRoot = findDirectChild(datasetsRoot, dataset.id); + if (datasetRoot) { + dataset.rootNode = refFor("studio", datasetRoot); + return datasetRoot; + } + } + + return resolve(dataset.rootNode); +} + +tsd::scene::LayerNodeRef ProjectContext::resolveLightRigRoot(LightRig &rig) +{ + if (!m_ctx) + return {}; + + auto *layer = m_ctx->tsd.scene.layer("studio"); + if (layer) { + auto lightRigsRoot = findDirectChild(layer->root(), "lightRigs"); + auto rigRoot = findDirectChild(lightRigsRoot, rig.id); + if (rigRoot) { + rig.rootNode = refFor("studio", rigRoot); + return rigRoot; + } + } + + return resolve(rig.rootNode); +} + +tsd::scene::Object *ProjectContext::resolveShotCamera(Shot &shot) +{ + if (!m_ctx) + return nullptr; + + const auto cameraName = shot.id + "_camera"; + const auto &cameras = m_ctx->tsd.scene.objectDB().camera; + tsd::core::foreach_item_const(cameras, [&](const tsd::scene::Camera *camera) { + if (camera && camera->name() == cameraName) + shot.camera = {ANARI_CAMERA, camera->index()}; + }); + + return resolve(shot.camera); +} + +void ProjectContext::ensureRendererDefaults(Shot &shot) +{ + if (!shot.renderSettings.rendererLibrary.empty()) + return; + + if (!m_ctx) + return; + + for (const auto &lib : m_ctx->anari.libraryList()) { + if (lib != "{none}") { + shot.renderSettings.rendererLibrary = lib; + break; + } + } +} + +LightRig *ProjectContext::createLightRig(const std::string &name) +{ + if (!m_ctx) + return nullptr; + + LightRig rig; + rig.id = project::nextLightRigId(m_project); + rig.name = name.empty() + ? ("Light Rig " + std::to_string(m_project.lightRigs.size() + 1)) + : name; + + auto rigRoot = ensureChild(ensureLightRigsRoot(), rig.id.c_str()); + rig.rootNode = refFor("studio", rigRoot); + m_project.lightRigs.push_back(std::move(rig)); + m_project.markDirty(); + return &m_project.lightRigs.back(); +} + +tsd::scene::LayerNodeRef ProjectContext::addLightToRig( + LightRig &rig, const std::string &subtype) +{ + if (!m_ctx) + return {}; + + auto rigRoot = resolveLightRigRoot(rig); + if (!rigRoot) + return {}; + + const auto lightSubtype = + subtype.empty() ? std::string("directional") : subtype; + auto light = m_ctx->tsd.scene.createObject(lightSubtype); + const auto lightName = + lightSubtype + "Light_" + std::to_string(light->index()); + light->setName(lightName); + if (lightSubtype == "directional") { + light->setParameter("direction", tsd::math::float2(0.f, 240.f)); + light->setParameter("irradiance", 1.f); + } + + auto node = + m_ctx->tsd.scene.insertChildObjectNode(rigRoot, light, lightName.c_str()); + m_project.markDirty(); + applyActiveShot(); + return node; +} + +bool ProjectContext::removeLightFromRig( + LightRig &rig, tsd::scene::LayerNodeRef lightNode) +{ + if (!m_ctx || !lightNode) + return false; + + auto rigRoot = resolveLightRigRoot(rig); + if (!rigRoot) + return false; + + auto *layer = (*rigRoot)->layer(); + if (!layer || !layer->isAncestorOf(rigRoot, lightNode) + || !(*lightNode)->isObject() || (*lightNode)->type() != ANARI_LIGHT) + return false; + + m_ctx->tsd.scene.removeNode(lightNode, true); + m_project.markDirty(); + applyActiveShot(); + return true; +} + +int ProjectContext::shotUseCount(const LightRigID &id) const +{ + return static_cast(std::count_if(m_project.shots.begin(), + m_project.shots.end(), + [&](const Shot &shot) { return shot.lightRigId == id; })); +} + +bool ProjectContext::removeLightRig(const LightRigID &id) +{ + if (!m_ctx) + return false; + + auto itr = std::find_if(m_project.lightRigs.begin(), + m_project.lightRigs.end(), + [&](const LightRig &rig) { return rig.id == id; }); + if (itr == m_project.lightRigs.end()) + return false; + + auto rigRoot = resolveLightRigRoot(*itr); + if (rigRoot) + m_ctx->tsd.scene.removeNode(rigRoot, true); + + for (auto &shot : m_project.shots) { + if (shot.lightRigId == id) + shot.lightRigId.clear(); + } + + m_project.lightRigs.erase(itr); + m_project.markDirty(); + applyActiveShot(); + return true; +} + +LightRig *ProjectContext::ensureDefaultLightRig() +{ + if (!m_project.lightRigs.empty()) + return &m_project.lightRigs.front(); + + auto *rig = createLightRig("Default"); + if (!rig) + return nullptr; + + addLightToRig(*rig, "directional"); + if (auto root = resolveLightRigRoot(*rig)) { + auto *layer = (*root)->layer(); + layer->traverse(root, [&](auto &node, int) { + if (node->isObject() && node->type() == ANARI_LIGHT) { + if (auto *light = node->getObject()) + light->setName("mainLight"); + node->name() = "mainLight"; + return false; + } + return true; + }); + } + return rig; +} + +void ProjectContext::createUnsavedProject() +{ + resetScene(); + + m_project = {}; + m_project.name = "Untitled"; + + auto datasetsRoot = ensureDatasetsRoot(); + auto shotsRoot = ensureShotsRoot(); + auto *defaultRig = ensureDefaultLightRig(); + (void)datasetsRoot; + + Shot shot; + shot.id = project::nextShotId(m_project); + shot.name = "Shot 1"; + shot.renderSettings.outputFilePrefix = shot.id; + ensureRendererDefaults(shot); + + auto camera = m_ctx->tsd.scene.createObject( + tsd::scene::tokens::camera::perspective); + camera->setName(shot.id + "_camera"); + shot.camera = {ANARI_CAMERA, camera.index()}; + shot.cameraRig.current = + shot_camera_rig::manipulatorStateFromManipulator(m_ctx->view.manipulator); + tsd::rendering::updateCameraObject(*camera, m_ctx->view.manipulator); + + ensureChild(shotsRoot, shot.id.c_str()); + if (defaultRig) + shot.lightRigId = defaultRig->id; + + m_project.shots.push_back(std::move(shot)); + m_project.activeShotId = m_project.shots.front().id; + m_project.markClean(); + syncAnimationManagerToActiveShot(); + applyActiveShot(); +} + +bool ProjectContext::addShot(const std::string &name) +{ + if (!m_ctx) + return false; + + Shot shot; + shot.id = project::nextShotId(m_project); + shot.name = name.empty() + ? ("Shot " + std::to_string(m_project.shots.size() + 1)) + : name; + shot.renderSettings.outputFilePrefix = shot.id; + ensureRendererDefaults(shot); + + for (const auto &dataset : m_project.datasets) { + if (dataset.status == DatasetStatus::Available) + shot.datasetBindings.push_back({dataset.id, true}); + } + + auto camera = m_ctx->tsd.scene.createObject( + tsd::scene::tokens::camera::perspective); + camera->setName(shot.id + "_camera"); + shot.camera = {ANARI_CAMERA, camera.index()}; + shot.cameraRig.current = + shot_camera_rig::manipulatorStateFromManipulator(m_ctx->view.manipulator); + tsd::rendering::updateCameraObject(*camera, m_ctx->view.manipulator); + + ensureChild(ensureShotsRoot(), shot.id.c_str()); + if (auto *defaultRig = ensureDefaultLightRig()) + shot.lightRigId = defaultRig->id; + + m_project.activeShotId = shot.id; + m_project.shots.push_back(std::move(shot)); + m_project.markDirty(); + syncAnimationManagerToActiveShot(); + applyActiveShot(); + return true; +} + +static DatasetSourceMetadata collectSourceMetadata( + const std::filesystem::path &sourcePath, + const std::filesystem::path &projectDirectory) +{ + DatasetSourceMetadata metadata; + std::error_code ec; + auto absolute = std::filesystem::absolute(sourcePath, ec); + if (ec) + absolute = sourcePath; + absolute = absolute.lexically_normal(); + metadata.absolutePath = absolute.string(); + + if (!projectDirectory.empty()) { + auto relative = std::filesystem::relative(absolute, projectDirectory, ec); + if (!ec) + metadata.projectRelativePath = relative.string(); + } + + metadata.fileSize = std::filesystem::is_regular_file(absolute, ec) + ? std::filesystem::file_size(absolute, ec) + : 0; + + auto modified = std::filesystem::last_write_time(absolute, ec); + if (!ec) { + metadata.modifiedTime = modified.time_since_epoch().count(); + } + + return metadata; +} + +static DatasetSourceFile sourceFileFromMetadata( + const DatasetSourceMetadata &metadata) +{ + return {metadata.absolutePath, + metadata.projectRelativePath, + metadata.fileSize, + metadata.modifiedTime}; +} + +Dataset *ProjectContext::addStaticDataset(const std::string &name, + const std::filesystem::path &sourcePath, + tsd::io::ImporterType importerType) +{ + if (!m_ctx) + return nullptr; + + Dataset dataset; + dataset.id = project::nextDatasetId(m_project); + dataset.name = name.empty() ? dataset.id : name; + dataset.sourceKind = DatasetSourceKind::Static; + dataset.importerType = toString(importerType); + dataset.source = + collectSourceMetadata(sourcePath, m_project.projectDirectory); + dataset.status = DatasetStatus::Importing; + + auto datasetRoot = ensureChild(ensureDatasetsRoot(), dataset.id.c_str()); + dataset.rootNode = refFor("studio", datasetRoot); + + auto datasetIndex = m_project.datasets.size(); + m_project.datasets.push_back(std::move(dataset)); + auto &record = m_project.datasets.back(); + + try { + tsd::io::import_file(m_ctx->tsd.scene, + m_ctx->tsd.animationMgr, + {importerType, sourcePath.string()}, + datasetRoot); + record.status = DatasetStatus::Available; + for (auto &shot : m_project.shots) + shot::setDatasetBinding(shot, record.id, &shot == project::activeShot(m_project)); + } catch (const std::exception &e) { + record.status = DatasetStatus::ImportFailed; + tsd::core::logError("[SciVisStudio] Dataset import failed for '%s': %s", + sourcePath.string().c_str(), + e.what()); + } catch (...) { + record.status = DatasetStatus::ImportFailed; + tsd::core::logError("[SciVisStudio] Dataset import failed for '%s'", + sourcePath.string().c_str()); + } + + (void)datasetIndex; + m_project.markDirty(); + applyActiveShot(); + return &record; +} + +Dataset *ProjectContext::addFileAnimationDataset(const std::string &name, + const std::vector &sourcePaths, + tsd::io::ImporterType importerType, + const FileAnimationDatasetOptions &options) +{ + if (!m_ctx || sourcePaths.empty()) + return nullptr; + + Dataset dataset; + dataset.id = project::nextDatasetId(m_project); + dataset.name = name.empty() ? dataset.id : name; + dataset.sourceKind = DatasetSourceKind::TimeSeries; + dataset.importerType = toString(importerType); + dataset.status = DatasetStatus::Importing; + dataset.source = + collectSourceMetadata(sourcePaths.front(), m_project.projectDirectory); + + std::vector importPaths; + importPaths.reserve(sourcePaths.size()); + dataset.sourceFiles.reserve(sourcePaths.size()); + for (const auto &path : sourcePaths) { + auto metadata = collectSourceMetadata(path, m_project.projectDirectory); + dataset.sourceFiles.push_back(sourceFileFromMetadata(metadata)); + importPaths.push_back(metadata.absolutePath); + } + + auto datasetRoot = ensureChild(ensureDatasetsRoot(), dataset.id.c_str()); + dataset.rootNode = refFor("studio", datasetRoot); + + m_project.datasets.push_back(std::move(dataset)); + auto &record = m_project.datasets.back(); + + try { + tsd::core::logStatus( + "[SciVisStudio] Importing file animation dataset '%s' with %zu frames", + record.name.c_str(), + importPaths.size()); + tsd::io::import_animations(m_ctx->tsd.scene, + m_ctx->tsd.animationMgr, + {{importerType, importPaths}}, + datasetRoot); + + if (!hasChildNodes(datasetRoot)) { + record.status = DatasetStatus::ImportFailed; + tsd::core::logError( + "[SciVisStudio] File animation dataset import created no scene objects for '%s'", + record.name.c_str()); + } else { + record.status = DatasetStatus::Available; + if (auto *activeShot = project::activeShot(m_project)) { + for (const auto &dataset : m_project.datasets) { + if (dataset.id == record.id + || dataset.sourceKind != DatasetSourceKind::TimeSeries) + continue; + const auto *binding = + shot::findDatasetBinding(*activeShot, dataset.id); + if (binding && binding->enabled + && dataset.sourceFiles.size() != sourcePaths.size()) { + tsd::core::logWarning( + "[SciVisStudio] Enabled file animation datasets have different frame counts: '%s' has %zu frames, '%s' has %zu frames", + dataset.name.c_str(), + dataset.sourceFiles.size(), + record.name.c_str(), + sourcePaths.size()); + } + } + } + for (auto &shot : m_project.shots) + shot::setDatasetBinding( + shot, record.id, &shot == project::activeShot(m_project)); + + if (auto *activeShot = project::activeShot(m_project)) { + if (options.setActiveShotFrameCount) + activeShot->frameCount = static_cast(sourcePaths.size()); + activeShot->currentFrame = 0; + activeShot->playing = false; + } + syncAnimationManagerToActiveShot(); + m_ctx->tsd.animationMgr.setAnimationFrame(0); + tsd::core::logStatus( + "[SciVisStudio] Imported file animation dataset '%s' (%zu frames)", + record.name.c_str(), + importPaths.size()); + } + } catch (const std::exception &e) { + record.status = DatasetStatus::ImportFailed; + tsd::core::logError( + "[SciVisStudio] File animation dataset import failed for '%s': %s", + record.name.c_str(), + e.what()); + } catch (...) { + record.status = DatasetStatus::ImportFailed; + tsd::core::logError( + "[SciVisStudio] File animation dataset import failed for '%s'", + record.name.c_str()); + } + + m_project.markDirty(); + applyActiveShot(); + return &record; +} + +void ProjectContext::applyActiveShot() +{ + if (!m_ctx) + return; + + auto *shot = project::activeShot(m_project); + if (!shot) + return; + + std::vector changedLayers; + auto setNodeEnabled = [&](tsd::scene::LayerNodeRef node, bool enabled) { + if (node) { + if ((*node)->isEnabled() == enabled) + return; + + (*node)->setEnabled(enabled); + auto *layer = (*node)->layer(); + if (layer + && std::find(changedLayers.begin(), changedLayers.end(), layer) + == changedLayers.end()) + changedLayers.push_back(layer); + } + }; + + for (auto &rig : m_project.lightRigs) { + setNodeEnabled(resolveLightRigRoot(rig), rig.id == shot->lightRigId); + } + + for (auto &dataset : m_project.datasets) { + bool enabled = false; + if (const auto *binding = shot::findDatasetBinding(*shot, dataset.id)) + enabled = binding->enabled; + setNodeEnabled(resolveDatasetRoot(dataset), enabled); + } + + for (auto *layer : changedLayers) + m_ctx->tsd.scene.signalLayerStructureChanged(layer); + + auto sampled = shot_camera_rig::sampleCameraRig(shot->cameraRig, shot->currentFrame); + shot_camera_rig::applyManipulatorState(m_ctx->view.manipulator, sampled); + + if (auto *obj = resolveShotCamera(*shot)) { + auto *camera = static_cast(obj); + tsd::rendering::updateCameraObject(*camera, m_ctx->view.manipulator); + } +} + +void ProjectContext::syncAnimationManagerToActiveShot() +{ + if (!m_ctx) + return; + + auto *shot = project::activeShot(m_project); + if (!shot) + return; + + shot->frameCount = std::max(1, shot->frameCount); + shot->currentFrame = std::clamp(shot->currentFrame, 0, shot->frameCount - 1); + shot->fps = std::max(1.f, shot->fps); + + m_syncingAnimationManager = true; + + auto &animMgr = m_ctx->tsd.animationMgr; + animMgr.setAnimationTotalFrames(std::max(2, shot->frameCount)); + animMgr.setAnimationFPS(shot->fps); + animMgr.setLoop(shot->loop); + animMgr.setAnimationFrame(shot->currentFrame); + if (shot->playing) + animMgr.play(); + else + animMgr.stop(); + + m_syncingAnimationManager = false; +} + +void ProjectContext::updateActiveShotFromAnimationTime() +{ + if (!m_ctx || m_syncingAnimationManager) + return; + + auto *shot = project::activeShot(m_project); + if (!shot) + return; + + const auto &animMgr = m_ctx->tsd.animationMgr; + shot->frameCount = std::max(1, shot->frameCount); + shot->currentFrame = + std::clamp(animMgr.getAnimationFrame(), 0, shot->frameCount - 1); + shot->playing = animMgr.isPlaying(); + applyActiveShot(); +} + +bool ProjectContext::saveProject(const std::filesystem::path &directory, + tsd::core::DataNode *windows, + const std::string &layout, + tsd::core::DataNode *settings, + std::string *error) +{ + if (!m_ctx) { + if (error) + *error = "missing TSD application context"; + return false; + } + + std::error_code ec; + const auto manifest = directory / PROJECT_MANIFEST_FILENAME; + const bool savingCurrent = !m_project.projectDirectory.empty() + && std::filesystem::equivalent(directory, m_project.projectDirectory, ec); + + if (std::filesystem::exists(manifest) && !savingCurrent) { + if (error) + *error = "target directory already contains project.tsd"; + return false; + } + + std::filesystem::create_directories(directory, ec); + if (ec) { + if (error) + *error = "failed to create project directory: " + ec.message(); + return false; + } + + std::filesystem::create_directories(directory / "renders", ec); + if (ec) { + if (error) + *error = "failed to create renders directory: " + ec.message(); + return false; + } + + m_project.projectDirectory = directory; + if (m_project.name.empty() || m_project.name == "Untitled") + m_project.name = directory.filename().string().empty() + ? std::string("Untitled") + : directory.filename().string(); + + tsd::core::DataTree tree; + auto &root = tree.root(); + tsd::core::writeDataTreeMetadata(root, + {tsd::core::DATA_TREE_METADATA_ENVELOPE_VERSION, + PROJECT_FILE_TYPE, + PROJECT_SCHEMA, + SCHEMA_VERSION}); + projectToNode(m_project, root["scivisStudio"]); + tsd::io::save_Scene( + m_ctx->tsd.scene, root["context"], false, &m_ctx->tsd.animationMgr); + + if (windows) + root["windows"] = *windows; + if (!layout.empty()) + root["layout"] = layout; + if (settings) + root["settings"] = *settings; + + if (!tree.save(manifest.string().c_str())) { + if (error) + *error = "failed to write project.tsd"; + return false; + } + + m_project.markClean(); + tsd::core::logStatus( + "[SciVisStudio] Saved project '%s'", directory.string().c_str()); + return true; +} + +bool ProjectContext::openProject(const std::filesystem::path &directory, + tsd::core::DataNode *windowsOut, + std::string *layoutOut, + tsd::core::DataNode *settingsOut, + std::string *error) +{ + auto validation = validateProjectRoot(directory); + if (!validation.ok) { + if (error) + *error = validation.error; + return false; + } + + tsd::core::DataTree tree; + if (!tree.load(validation.manifestPath.string().c_str())) { + if (error) + *error = "failed to load project.tsd"; + return false; + } + + Project loadedProject; + if (auto *model = tree.root().child("scivisStudio")) + nodeToProject(*model, loadedProject); + else { + if (error) + *error = "project.tsd is missing scivisStudio section"; + return false; + } + + auto &root = tree.root(); + resetScene(); + m_syncingAnimationManager = true; + if (auto *context = root.child("context")) + tsd::io::load_Scene(m_ctx->tsd.scene, *context, &m_ctx->tsd.animationMgr); + m_syncingAnimationManager = false; + + loadedProject.projectDirectory = directory; + loadedProject.markClean(); + m_project = std::move(loadedProject); + auto manifestMetadata = tsd::core::readDataTreeMetadata(root); + const int loadedSchemaVersion = manifestMetadata.found() + ? manifestMetadata.metadata->schemaVersion + : root["schemaVersion"].getValueOr(1); + if (loadedSchemaVersion < 2) + migrateLegacyShotLightsToLightRigs(); + markMissingDatasets(); + refreshRuntimeRefs(); + syncAnimationManagerToActiveShot(); + + if (windowsOut) { + windowsOut->reset(); + if (auto *windows = root.child("windows")) + *windowsOut = *windows; + } + + if (layoutOut) { + layoutOut->clear(); + if (auto *layout = root.child("layout")) + *layoutOut = layout->getValueAs(); + } + + if (settingsOut) { + settingsOut->reset(); + if (auto *settings = root.child("settings")) + *settingsOut = *settings; + } + + applyActiveShot(); + tsd::core::logStatus( + "[SciVisStudio] Opened project '%s'", directory.string().c_str()); + return true; +} + +void ProjectContext::markMissingDatasets() +{ + for (auto &dataset : m_project.datasets) { + if (dataset.sourceKind == DatasetSourceKind::TimeSeries) { + const bool missingSource = dataset.sourceFiles.empty() + || std::any_of(dataset.sourceFiles.begin(), + dataset.sourceFiles.end(), + [this](const DatasetSourceFile &sourceFile) { + return !sourceFileIsRegular(sourceFile); + }); + if (missingSource) + dataset.status = DatasetStatus::Missing; + continue; + } + + if (dataset.sourceKind != DatasetSourceKind::Static) + continue; + + DatasetSourceFile sourceFile{dataset.source.absolutePath, + dataset.source.projectRelativePath, + dataset.source.fileSize, + dataset.source.modifiedTime}; + if (!sourceFile.absolutePath.empty() && !sourceFileIsRegular(sourceFile)) + dataset.status = DatasetStatus::Missing; + } +} + +std::filesystem::path ProjectContext::resolveSourceFilePath( + const DatasetSourceFile &sourceFile) const +{ + if (!sourceFile.projectRelativePath.empty() + && !m_project.projectDirectory.empty()) { + auto relativePath = + (m_project.projectDirectory / sourceFile.projectRelativePath) + .lexically_normal(); + std::error_code ec; + if (std::filesystem::is_regular_file(relativePath, ec) && !ec) + return relativePath; + } + + if (!sourceFile.absolutePath.empty()) + return std::filesystem::path(sourceFile.absolutePath).lexically_normal(); + + return {}; +} + +bool ProjectContext::sourceFileIsRegular( + const DatasetSourceFile &sourceFile) const +{ + auto path = resolveSourceFilePath(sourceFile); + if (path.empty()) + return false; + + std::error_code ec; + return std::filesystem::is_regular_file(path, ec) && !ec; +} + +void ProjectContext::refreshRuntimeRefs() +{ + for (auto &dataset : m_project.datasets) + resolveDatasetRoot(dataset); + + for (auto &rig : m_project.lightRigs) + resolveLightRigRoot(rig); + + for (auto &shot : m_project.shots) { + resolveShotCamera(shot); + } +} + +void ProjectContext::migrateLegacyShotLightsToLightRigs() +{ + if (!m_ctx || !m_project.lightRigs.empty()) + return; + + auto *layer = m_ctx->tsd.scene.layer("studio"); + if (!layer) + return; + + auto lightRigsRoot = ensureLightRigsRoot(); + auto shotsRoot = findDirectChild(layer->root(), "shots"); + for (auto &shot : m_project.shots) { + auto shotRoot = findDirectChild(shotsRoot, shot.id); + auto legacyLights = findDirectChild(shotRoot, "lights"); + if (!legacyLights) + continue; + + LightRig rig; + rig.id = project::nextLightRigId(m_project); + rig.name = + shot.name.empty() ? (shot.id + " Lights") : (shot.name + " Lights"); + if (auto existing = findDirectChild(lightRigsRoot, rig.id)) + m_ctx->tsd.scene.removeNode(existing, true); + + legacyLights->container()->move_subtree(legacyLights, lightRigsRoot); + (*legacyLights)->name() = rig.id; + rig.rootNode = refFor("studio", legacyLights); + shot.lightRigId = rig.id; + m_project.lightRigs.push_back(std::move(rig)); + } + m_ctx->tsd.scene.signalLayerStructureChanged(layer); +} + +const char *toString(tsd::io::ImporterType importerType) +{ + switch (importerType) { + case tsd::io::ImporterType::AGX: + return "AGX"; + case tsd::io::ImporterType::ASSIMP: + return "ASSIMP"; + case tsd::io::ImporterType::ASSIMP_FLAT: + return "ASSIMP_FLAT"; + case tsd::io::ImporterType::AXYZ: + return "AXYZ"; + case tsd::io::ImporterType::DLAF: + return "DLAF"; + case tsd::io::ImporterType::E57XYZ: + return "E57XYZ"; + case tsd::io::ImporterType::ENSIGHT: + return "ENSIGHT"; + case tsd::io::ImporterType::GLTF: + return "GLTF"; + case tsd::io::ImporterType::HDRI: + return "HDRI"; + case tsd::io::ImporterType::HSMESH: + return "HSMESH"; + case tsd::io::ImporterType::NBODY: + return "NBODY"; + case tsd::io::ImporterType::OBJ: + return "OBJ"; + case tsd::io::ImporterType::PDB: + return "PDB"; + case tsd::io::ImporterType::PLY: + return "PLY"; + case tsd::io::ImporterType::POINTSBIN_MULTIFILE: + return "POINTSBIN_MULTIFILE"; + case tsd::io::ImporterType::PT: + return "PT"; + case tsd::io::ImporterType::SILO: + return "SILO"; + case tsd::io::ImporterType::SMESH: + return "SMESH"; + case tsd::io::ImporterType::SMESH_ANIMATION: + return "SMESH_ANIMATION"; + case tsd::io::ImporterType::SWC: + return "SWC"; + case tsd::io::ImporterType::TRK: + return "TRK"; + case tsd::io::ImporterType::USD: + return "USD"; + case tsd::io::ImporterType::VTP: + return "VTP"; + case tsd::io::ImporterType::VTU: + return "VTU"; + case tsd::io::ImporterType::XYZDP: + return "XYZDP"; + case tsd::io::ImporterType::VOLUME: + return "VOLUME"; + case tsd::io::ImporterType::VOLUME_ANIMATION: + return "VOLUME_ANIMATION"; + case tsd::io::ImporterType::TSD: + return "TSD"; + case tsd::io::ImporterType::XF: + return "XF"; + case tsd::io::ImporterType::BLANK: + return "BLANK"; + case tsd::io::ImporterType::NONE: + return "NONE"; + } + return "NONE"; +} + +tsd::io::ImporterType importerTypeFromString(const std::string &s) +{ + for (int i = 0; i <= static_cast(tsd::io::ImporterType::NONE); ++i) { + auto type = static_cast(i); + if (s == toString(type)) + return type; + } + return tsd::io::ImporterType::NONE; +} + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/ProjectContext.h b/tsd/apps/interactive/scivisStudio/ProjectContext.h new file mode 100644 index 000000000..60adcf3cf --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/ProjectContext.h @@ -0,0 +1,97 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "Project.h" + +#include "tsd/app/Context.h" +#include "tsd/io/importers.hpp" + +#include +#include +#include + +namespace tsd::scivis_studio { + +struct FileAnimationDatasetOptions +{ + bool setActiveShotFrameCount{true}; +}; + +struct ProjectContext +{ + ProjectContext() = default; + explicit ProjectContext(tsd::app::Context *ctx); + + void setAppContext(tsd::app::Context *ctx); + tsd::app::Context *appContext() const; + + Project &project(); + const Project &project() const; + + void createUnsavedProject(); + bool addShot(const std::string &name = ""); + Dataset *addStaticDataset(const std::string &name, + const std::filesystem::path &sourcePath, + tsd::io::ImporterType importerType); + Dataset *addFileAnimationDataset(const std::string &name, + const std::vector &sourcePaths, + tsd::io::ImporterType importerType, + const FileAnimationDatasetOptions &options = {}); + void applyActiveShot(); + void syncAnimationManagerToActiveShot(); + + bool saveProject(const std::filesystem::path &directory, + tsd::core::DataNode *windows = nullptr, + const std::string &layout = "", + tsd::core::DataNode *settings = nullptr, + std::string *error = nullptr); + bool openProject(const std::filesystem::path &directory, + tsd::core::DataNode *windowsOut = nullptr, + std::string *layoutOut = nullptr, + tsd::core::DataNode *settingsOut = nullptr, + std::string *error = nullptr); + + tsd::scene::LayerNodeRef resolve(const SceneNodeRef &ref) const; + tsd::scene::Object *resolve(const SceneObjectRef &ref) const; + SceneNodeRef refFor( + const std::string &layerName, tsd::scene::LayerNodeRef ref) const; + tsd::scene::LayerNodeRef resolveDatasetRoot(Dataset &dataset); + tsd::scene::LayerNodeRef resolveLightRigRoot(LightRig &rig); + tsd::scene::Object *resolveShotCamera(Shot &shot); + std::filesystem::path resolveSourceFilePath( + const DatasetSourceFile &sourceFile) const; + bool sourceFileIsRegular(const DatasetSourceFile &sourceFile) const; + LightRig *createLightRig(const std::string &name = ""); + bool removeLightRig(const LightRigID &id); + tsd::scene::LayerNodeRef addLightToRig( + LightRig &rig, const std::string &subtype); + bool removeLightFromRig(LightRig &rig, tsd::scene::LayerNodeRef lightNode); + int shotUseCount(const LightRigID &id) const; + + private: + tsd::scene::LayerNodeRef ensureChild( + tsd::scene::LayerNodeRef parent, const char *name); + tsd::scene::LayerNodeRef ensureStudioRoot(); + tsd::scene::LayerNodeRef ensureDatasetsRoot(); + tsd::scene::LayerNodeRef ensureShotsRoot(); + tsd::scene::LayerNodeRef ensureLightRigsRoot(); + void resetScene(); + void ensureRendererDefaults(Shot &shot); + LightRig *ensureDefaultLightRig(); + void migrateLegacyShotLightsToLightRigs(); + void markMissingDatasets(); + void refreshRuntimeRefs(); + void installAnimationManagerCallback(); + void updateActiveShotFromAnimationTime(); + + tsd::app::Context *m_ctx{nullptr}; + Project m_project; + bool m_syncingAnimationManager{false}; +}; + +const char *toString(tsd::io::ImporterType importerType); +tsd::io::ImporterType importerTypeFromString(const std::string &s); + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp b/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp new file mode 100644 index 000000000..0fdd1de96 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp @@ -0,0 +1,337 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "ProjectSerialization.h" + +#include "tsd/core/DataTreeMetadata.hpp" +#include "tsd/io/serialization.hpp" + +#include + +namespace tsd::scivis_studio { + +static void manipulatorStateToNode( + const ManipulatorState &state, tsd::core::DataNode &node) +{ + tsd::io::cameraPoseToNode(state.orbit, node["orbit"]); +} + +static void nodeToManipulatorState( + tsd::core::DataNode &node, ManipulatorState &state) +{ + if (auto *orbit = node.child("orbit")) + tsd::io::nodeToCameraPose(*orbit, state.orbit); +} + +static void cameraRigToNode(const ShotCameraRig &rig, tsd::core::DataNode &node) +{ + manipulatorStateToNode(rig.current, node["current"]); + auto &keyframes = node["keyframes"]; + for (const auto &keyframe : rig.keyframes) { + auto &kf = keyframes.append(); + kf["frame"] = keyframe.frame; + kf["name"] = keyframe.name; + kf["interpolationToNext"] = + shot_camera_rig::toString(keyframe.interpolationToNext); + manipulatorStateToNode(keyframe.manipulator, kf["manipulator"]); + } +} + +static void nodeToCameraRig(tsd::core::DataNode &node, ShotCameraRig &rig) +{ + if (auto *current = node.child("current")) + nodeToManipulatorState(*current, rig.current); + + rig.keyframes.clear(); + if (auto *keyframes = node.child("keyframes")) { + keyframes->foreach_child([&](tsd::core::DataNode &kf) { + CameraKeyframe keyframe; + keyframe.frame = kf["frame"].getValueOr(0); + keyframe.name = kf["name"].getValueOr(""); + keyframe.interpolationToNext = shot_camera_rig::interpolationFromString( + kf["interpolationToNext"].getValueOr("Linear")); + if (auto *manip = kf.child("manipulator")) + nodeToManipulatorState(*manip, keyframe.manipulator); + rig.keyframes.push_back(std::move(keyframe)); + }); + } + shot_camera_rig::sortKeyframes(rig); +} + +static void sourceMetadataToNode( + const DatasetSourceMetadata &source, tsd::core::DataNode &node) +{ + node["absolutePath"] = source.absolutePath; + node["projectRelativePath"] = source.projectRelativePath; + node["fileSize"] = source.fileSize; + node["modifiedTime"] = source.modifiedTime; +} + +static void sourceFileToNode( + const DatasetSourceFile &source, tsd::core::DataNode &node) +{ + node["absolutePath"] = source.absolutePath; + node["projectRelativePath"] = source.projectRelativePath; + node["fileSize"] = source.fileSize; + node["modifiedTime"] = source.modifiedTime; +} + +static void nodeToSourceMetadata( + tsd::core::DataNode &node, DatasetSourceMetadata &source) +{ + source.absolutePath = node["absolutePath"].getValueOr(""); + source.projectRelativePath = + node["projectRelativePath"].getValueOr(""); + source.fileSize = node["fileSize"].getValueOr(0); + source.modifiedTime = node["modifiedTime"].getValueOr(0); +} + +static void nodeToSourceFile( + tsd::core::DataNode &node, DatasetSourceFile &source) +{ + source.absolutePath = node["absolutePath"].getValueOr(""); + source.projectRelativePath = + node["projectRelativePath"].getValueOr(""); + source.fileSize = node["fileSize"].getValueOr(0); + source.modifiedTime = node["modifiedTime"].getValueOr(0); +} + +void projectToNode(const Project &project, tsd::core::DataNode &node) +{ + node.reset(); + node["name"] = project.name; + node["projectDirectory"] = project.projectDirectory.string(); + node["activeShot"] = project.activeShotId; + node["dirty"] = project.dirty; + + auto &datasets = node["datasets"]; + for (const auto &dataset : project.datasets) { + auto &d = datasets.append(); + d["id"] = dataset.id; + d["name"] = dataset.name; + d["sourceKind"] = dataset::toString(dataset.sourceKind); + d["importerType"] = dataset.importerType; + d["status"] = dataset::toString(dataset.status); + + sourceMetadataToNode(dataset.source, d["source"]); + + auto &sourceFiles = d["sourceFiles"]; + for (const auto &sourceFile : dataset.sourceFiles) + sourceFileToNode(sourceFile, sourceFiles.append()); + } + + auto &shots = node["shots"]; + for (const auto &shot : project.shots) { + auto &s = shots.append(); + s["id"] = shot.id; + s["name"] = shot.name; + s["frameCount"] = shot.frameCount; + s["fps"] = shot.fps; + s["currentFrame"] = shot.currentFrame; + s["playing"] = shot.playing; + s["loop"] = shot.loop; + s["lightRigId"] = shot.lightRigId; + cameraRigToNode(shot.cameraRig, s["cameraRig"]); + + auto &render = s["renderSettings"]; + render["width"] = shot.renderSettings.width; + render["height"] = shot.renderSettings.height; + render["samples"] = shot.renderSettings.samples; + render["rendererLibrary"] = shot.renderSettings.rendererLibrary; + render["rendererObjectIndex"] = + static_cast(shot.renderSettings.rendererObjectIndex); + render["rendererSubtype"] = shot.renderSettings.rendererSubtype; + render["outputFilePrefix"] = shot.renderSettings.outputFilePrefix; + + auto &bindings = s["datasetBindings"]; + for (const auto &binding : shot.datasetBindings) { + auto &b = bindings.append(); + b["datasetId"] = binding.datasetId; + b["enabled"] = binding.enabled; + } + } + + auto &lightRigs = node["lightRigs"]; + for (const auto &rig : project.lightRigs) { + auto &r = lightRigs.append(); + r["id"] = rig.id; + r["name"] = rig.name; + } + + auto &colorMaps = node["colorMaps"]; + for (const auto &colorMap : project.colorMaps) { + auto &c = colorMaps.append(); + c["id"] = colorMap.id; + c["name"] = colorMap.name; + } +} + +bool nodeToProject(tsd::core::DataNode &node, Project &project) +{ + Project out; + out.name = node["name"].getValueOr("Untitled"); + out.projectDirectory = node["projectDirectory"].getValueOr(""); + out.activeShotId = node["activeShot"].getValueOr(""); + out.dirty = node["dirty"].getValueOr(false); + + if (auto *datasets = node.child("datasets")) { + datasets->foreach_child([&](tsd::core::DataNode &d) { + Dataset dataset; + dataset.id = d["id"].getValueOr(""); + dataset.name = d["name"].getValueOr(dataset.id); + dataset.sourceKind = dataset::sourceKindFromString( + d["sourceKind"].getValueOr("Static")); + dataset.importerType = d["importerType"].getValueOr("NONE"); + dataset.status = dataset::statusFromString( + d["status"].getValueOr("Missing")); + + if (auto *source = d.child("source")) + nodeToSourceMetadata(*source, dataset.source); + + if (auto *sourceFiles = d.child("sourceFiles")) { + sourceFiles->foreach_child([&](tsd::core::DataNode &f) { + DatasetSourceFile sourceFile; + nodeToSourceFile(f, sourceFile); + dataset.sourceFiles.push_back(std::move(sourceFile)); + }); + } + out.datasets.push_back(std::move(dataset)); + }); + } + + if (auto *shots = node.child("shots")) { + shots->foreach_child([&](tsd::core::DataNode &s) { + Shot shot; + shot.id = s["id"].getValueOr(""); + shot.name = s["name"].getValueOr(shot.id); + shot.frameCount = s["frameCount"].getValueOr(120); + shot.fps = s["fps"].getValueOr(24.f); + shot.currentFrame = s["currentFrame"].getValueOr(0); + shot.playing = s["playing"].getValueOr(false); + shot.loop = s["loop"].getValueOr(true); + shot.lightRigId = s["lightRigId"].getValueOr(""); + if (auto *cameraRig = s.child("cameraRig")) + nodeToCameraRig(*cameraRig, shot.cameraRig); + + if (auto *render = s.child("renderSettings")) { + shot.renderSettings.width = + (*render)["width"].getValueOr(1024); + shot.renderSettings.height = + (*render)["height"].getValueOr(768); + shot.renderSettings.samples = + (*render)["samples"].getValueOr(128); + shot.renderSettings.rendererLibrary = + (*render)["rendererLibrary"].getValueOr(""); + shot.renderSettings.rendererObjectIndex = + (*render)["rendererObjectIndex"].getValueOr( + TSD_INVALID_INDEX); + shot.renderSettings.rendererSubtype = + (*render)["rendererSubtype"].getValueOr("default"); + shot.renderSettings.outputFilePrefix = + (*render)["outputFilePrefix"].getValueOr(""); + } + + if (auto *bindings = s.child("datasetBindings")) { + bindings->foreach_child([&](tsd::core::DataNode &b) { + DatasetBinding binding; + binding.datasetId = b["datasetId"].getValueOr(""); + binding.enabled = b["enabled"].getValueOr(true); + shot.datasetBindings.push_back(std::move(binding)); + }); + } + out.shots.push_back(std::move(shot)); + }); + } + + if (auto *lightRigs = node.child("lightRigs")) { + lightRigs->foreach_child([&](tsd::core::DataNode &r) { + out.lightRigs.push_back({r["id"].getValueOr(""), + r["name"].getValueOr("")}); + }); + } + + if (auto *colorMaps = node.child("colorMaps")) { + colorMaps->foreach_child([&](tsd::core::DataNode &c) { + out.colorMaps.push_back({c["id"].getValueOr(""), + c["name"].getValueOr("")}); + }); + } + + if (out.activeShotId.empty() && !out.shots.empty()) + out.activeShotId = out.shots.front().id; + + project = std::move(out); + return true; +} + +ProjectValidationResult validateProjectRoot( + const std::filesystem::path &directory) +{ + ProjectValidationResult result; + result.manifestPath = directory / PROJECT_MANIFEST_FILENAME; + + if (!std::filesystem::exists(directory)) { + result.error = "project directory does not exist"; + return result; + } + + if (!std::filesystem::is_directory(directory)) { + result.error = "selected path is not a directory"; + return result; + } + + if (!std::filesystem::exists(result.manifestPath)) { + result.error = "project.tsd does not exist"; + return result; + } + + tsd::core::DataTree tree; + if (!tree.load(result.manifestPath.string().c_str())) { + result.error = "failed to load project.tsd"; + return result; + } + + auto &root = tree.root(); + auto metadataResult = tsd::core::readDataTreeMetadata(root); + if (metadataResult.malformed()) { + result.error = "malformed __tsd_metadata: " + metadataResult.message; + return result; + } + + if (metadataResult.found()) { + const auto &metadata = *metadataResult.metadata; + if (metadata.envelopeVersion + != tsd::core::DATA_TREE_METADATA_ENVELOPE_VERSION) { + result.error = "unsupported SciVis Studio metadata envelopeVersion"; + return result; + } + + if (metadata.fileType != PROJECT_FILE_TYPE + || metadata.schema != PROJECT_SCHEMA) { + result.error = "metadata schema is not SciVis Studio project"; + return result; + } + + if (metadata.schemaVersion < 1 || metadata.schemaVersion > SCHEMA_VERSION) { + result.error = "unsupported SciVis Studio schemaVersion"; + return result; + } + } else { + const auto kind = root["projectKind"].getValueOr(""); + if (kind != PROJECT_KIND) { + result.error = "missing __tsd_metadata"; + return result; + } + + const auto version = root["schemaVersion"].getValueOr(0); + if (version < 1 || version > SCHEMA_VERSION) { + result.error = "unsupported legacy SciVis Studio schemaVersion"; + return result; + } + } + + result.ok = true; + return result; +} + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/ProjectSerialization.h b/tsd/apps/interactive/scivisStudio/ProjectSerialization.h new file mode 100644 index 000000000..0171338f2 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/ProjectSerialization.h @@ -0,0 +1,34 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "Project.h" + +#include "tsd/core/DataTree.hpp" + +#include +#include + +namespace tsd::scivis_studio { + +constexpr const char *PROJECT_KIND = "SciVisStudio"; +constexpr const char *PROJECT_FILE_TYPE = "project"; +constexpr const char *PROJECT_SCHEMA = "tsd.scivis-studio.project"; +constexpr int SCHEMA_VERSION = 2; +constexpr const char *PROJECT_MANIFEST_FILENAME = "project.tsd"; + +struct ProjectValidationResult +{ + bool ok{false}; + std::string error; + std::filesystem::path manifestPath; +}; + +void projectToNode(const Project &project, tsd::core::DataNode &node); +bool nodeToProject(tsd::core::DataNode &node, Project &project); + +ProjectValidationResult validateProjectRoot( + const std::filesystem::path &directory); + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/RenderShot.cpp b/tsd/apps/interactive/scivisStudio/RenderShot.cpp new file mode 100644 index 000000000..e52cef23e --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/RenderShot.cpp @@ -0,0 +1,192 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "RenderShot.h" + +#include "tsd/app/ANARIDeviceManager.h" +#include "tsd/core/Logging.hpp" +#include "tsd/rendering/index/RenderIndexAllLayers.hpp" +#include "tsd/rendering/pipeline/ImagePipeline.h" +#include "tsd/rendering/pipeline/passes/AnariSceneRenderPass.h" +#include "tsd/rendering/pipeline/passes/SaveToFilePass.h" + +#include +#include +#include + +namespace tsd::scivis_studio { + + +namespace { + +anari::Device loadFirstAvailableDevice( + tsd::app::ANARIDeviceManager &deviceManager, std::string &libName) +{ + auto tryLoad = [&](const std::string &name) { + return deviceManager.loadDevice(name); + }; + + if (auto device = tryLoad(libName)) + return device; + + if (!libName.empty() && libName != "{none}") { + tsd::core::logWarning( + "[SciVisStudio] Failed to load ANARI device '%s'; falling back to a " + "default device", + libName.c_str()); + } + + for (const auto &fallback : deviceManager.libraryList()) { + if (fallback == libName) + continue; + if (auto device = tryLoad(fallback)) { + libName = fallback; + return device; + } + } + + libName.clear(); + return nullptr; +} + +} // namespace + +bool renderActiveShotToFrames( + ProjectContext &projectContext, RenderShotProgress *progress) +{ + auto *ctx = projectContext.appContext(); + auto *shot = project::activeShot(projectContext.project()); + if (!ctx || !shot) + return false; + + if (!projectContext.project().isSaved()) { + tsd::core::logError("[SciVisStudio] Cannot render an unsaved project"); + return false; + } + + auto *cameraObject = projectContext.resolveShotCamera(*shot); + if (!cameraObject || cameraObject->type() != ANARI_CAMERA) { + tsd::core::logError("[SciVisStudio] Active shot camera is missing"); + return false; + } + + const auto outputDirectory = + projectContext.project().projectDirectory / "renders" / shot->id; + std::error_code ec; + std::filesystem::create_directories(outputDirectory, ec); + if (ec) { + tsd::core::logError("[SciVisStudio] Failed to create render directory '%s'", + outputDirectory.string().c_str()); + return false; + } + + auto libName = shot->renderSettings.rendererLibrary; + auto device = loadFirstAvailableDevice(ctx->anari, libName); + if (!device) { + tsd::core::logError( + "[SciVisStudio] Failed to load an ANARI device for shot rendering"); + return false; + } + + projectContext.applyActiveShot(); + + auto *renderIndex = ctx->tsd.scene.updateDelegate() + .emplace( + ctx->tsd.scene, libName, device); + renderIndex->populate(); + + const auto rendererIndex = shot->renderSettings.rendererObjectIndex; + auto rendererObject = ctx->tsd.scene.getObject(ANARI_RENDERER, rendererIndex); + if (!rendererObject || rendererObject->rendererDeviceName() != libName) { + tsd::core::logError( + "[SciVisStudio] Renderer object index %zu is unavailable for ANARI " + "device '%s'", + rendererIndex, + libName.c_str()); + ctx->tsd.scene.updateDelegate().erase(renderIndex); + anari::release(device, device); + return false; + } + + auto renderer = renderIndex->renderer(rendererIndex); + if (!renderer) { + tsd::core::logError( + "[SciVisStudio] Failed to resolve renderer object index %zu", + rendererIndex); + ctx->tsd.scene.updateDelegate().erase(renderIndex); + anari::release(device, device); + return false; + } + + tsd::rendering::ImagePipeline pipeline; + pipeline.setDimensions( + shot->renderSettings.width, shot->renderSettings.height); + auto *anariPass = + pipeline.emplace_back(device); + anariPass->setRunAsync(false); + anariPass->setColorFormat(ANARI_UFIXED8_RGBA_SRGB); + anariPass->setWorld(renderIndex->world()); + anariPass->setRenderer(renderer); + anariPass->setCamera(renderIndex->camera(shot->camera.objectIndex)); + + auto *savePass = pipeline.emplace_back(); + savePass->setSingleShotMode(false); + + if (auto camera = renderIndex->camera(shot->camera.objectIndex)) { + anari::setParameter(device, + camera, + "aspect", + static_cast(shot->renderSettings.width) + / static_cast(shot->renderSettings.height)); + anari::commitParameters(device, camera); + } + + const int savedFrame = shot->currentFrame; + const bool savedPlaying = shot->playing; + const int totalFrames = std::max(1, shot->frameCount); + const auto prefix = shot->renderSettings.outputFilePrefix.empty() + ? shot->id + : shot->renderSettings.outputFilePrefix; + shot->playing = false; + projectContext.syncAnimationManagerToActiveShot(); + + tsd::core::logStatus("[SciVisStudio] Rendering %d frames to '%s'", + totalFrames, + outputDirectory.string().c_str()); + + bool completed = true; + for (int frame = 0; frame < totalFrames; ++frame) { + if (progress && progress->onFrame + && !progress->onFrame(frame, totalFrames)) { + tsd::core::logStatus( + "[SciVisStudio] Shot render canceled before frame %d/%d", + frame, + totalFrames); + completed = false; + break; + } + + ctx->tsd.animationMgr.setAnimationFrame(frame); + + std::ostringstream ss; + ss << prefix << '_' << std::setfill('0') << std::setw(4) << frame << ".png"; + savePass->setFilename((outputDirectory / ss.str()).string()); + + for (uint32_t sample = 0; sample < shot->renderSettings.samples; ++sample) { + savePass->setEnabled(sample + 1 == shot->renderSettings.samples); + pipeline.render(); + } + } + + shot->currentFrame = savedFrame; + shot->playing = savedPlaying; + projectContext.syncAnimationManagerToActiveShot(); + projectContext.applyActiveShot(); + + ctx->tsd.scene.updateDelegate().erase(renderIndex); + anari::release(device, device); + + return completed; +} + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/RenderShot.h b/tsd/apps/interactive/scivisStudio/RenderShot.h new file mode 100644 index 000000000..d0aecfd58 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/RenderShot.h @@ -0,0 +1,20 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "ProjectContext.h" + +#include + +namespace tsd::scivis_studio { + +struct RenderShotProgress +{ + std::function onFrame; +}; + +bool renderActiveShotToFrames( + ProjectContext &projectContext, RenderShotProgress *progress = nullptr); + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/RenderShotCLI.cpp b/tsd/apps/interactive/scivisStudio/RenderShotCLI.cpp new file mode 100644 index 000000000..f0b286952 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/RenderShotCLI.cpp @@ -0,0 +1,167 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "RenderShotCLI.h" + +#include +#include +#include + +namespace tsd::scivis_studio { + +bool parseRenderShotCommandLine(const std::vector &args, + RenderShotCommandLine &commandLine, + std::string &error) +{ + commandLine = {}; + error.clear(); + + for (size_t i = 1; i < args.size(); ++i) { + const auto &arg = args[i]; + if (arg == "-h" || arg == "--help") { + commandLine.showHelp = true; + return true; + } + + if (arg == "--shot") { + if (i + 1 >= args.size()) { + error = "--shot requires a shot ID"; + return false; + } + commandLine.shotId = args[++i]; + if (commandLine.shotId.empty()) { + error = "--shot requires a non-empty shot ID"; + return false; + } + continue; + } + + if (!arg.empty() && arg.front() == '-') { + error = "unknown option: " + arg; + return false; + } + + if (!commandLine.projectDirectory.empty()) { + error = "multiple project directories were specified"; + return false; + } + + commandLine.projectDirectory = arg; + } + + if (commandLine.projectDirectory.empty()) { + error = "missing project directory"; + return false; + } + + return true; +} + +std::string renderShotUsage(const std::string &programName) +{ + std::ostringstream out; + out << "usage: " << programName + << " [--shot ]\n"; + return out.str(); +} + +std::string formatShotList(const Project &project) +{ + std::ostringstream out; + out << "Available shots:\n"; + for (const auto &shot : project.shots) + out << " " << shot.id << " " << shot.name << '\n'; + return out.str(); +} + +Shot *findShotById(Project &project, const std::string &shotId) +{ + auto it = std::find_if(project.shots.begin(), + project.shots.end(), + [&](const Shot &shot) { return shot.id == shotId; }); + return it == project.shots.end() ? nullptr : &*it; +} + +const Shot *findShotById(const Project &project, const std::string &shotId) +{ + auto it = std::find_if(project.shots.begin(), + project.shots.end(), + [&](const Shot &shot) { return shot.id == shotId; }); + return it == project.shots.end() ? nullptr : &*it; +} + +static bool parseSelection(const std::string &input, size_t &selection) +{ + size_t value = 0; + const auto *begin = input.data(); + const auto *end = input.data() + input.size(); + auto result = std::from_chars(begin, end, value); + if (result.ec != std::errc{} || result.ptr != end) + return false; + + selection = value; + return true; +} + +Shot *selectShotForRender(Project &project, + const std::string &shotId, + bool interactive, + std::istream &input, + std::ostream &output, + std::string &error) +{ + error.clear(); + + if (!shotId.empty()) { + auto *shot = findShotById(project, shotId); + if (shot) + return shot; + + std::ostringstream out; + out << "unknown shot ID: " << shotId << "\n\n" << formatShotList(project); + error = out.str(); + return nullptr; + } + + if (project.shots.empty()) { + error = "project has no shots"; + return nullptr; + } + + if (project.shots.size() == 1) + return &project.shots.front(); + + if (!interactive) { + std::ostringstream out; + out << "multiple shots found; pass --shot \n\n" + << formatShotList(project); + error = out.str(); + return nullptr; + } + + output << "Multiple shots found:\n"; + for (size_t i = 0; i < project.shots.size(); ++i) { + const auto &shot = project.shots[i]; + output << " " << i + 1 << ". " << shot.id << " " << shot.name << '\n'; + } + output << "\nSelect shot [1-" << project.shots.size() << "]: "; + + std::string line; + if (!std::getline(input, line)) { + error = "failed to read shot selection"; + return nullptr; + } + + size_t selection = 0; + if (!parseSelection(line, selection) || selection < 1 + || selection > project.shots.size()) { + std::ostringstream out; + out << "invalid shot selection: " << line; + error = out.str(); + return nullptr; + } + + return &project.shots[selection - 1]; +} + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/RenderShotCLI.h b/tsd/apps/interactive/scivisStudio/RenderShotCLI.h new file mode 100644 index 000000000..2d9ef2142 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/RenderShotCLI.h @@ -0,0 +1,39 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "Project.h" + +#include +#include +#include +#include + +namespace tsd::scivis_studio { + +struct RenderShotCommandLine +{ + std::filesystem::path projectDirectory; + std::string shotId; + bool showHelp{false}; +}; + +bool parseRenderShotCommandLine(const std::vector &args, + RenderShotCommandLine &commandLine, + std::string &error); + +std::string renderShotUsage(const std::string &programName); +std::string formatShotList(const Project &project); + +Shot *findShotById(Project &project, const std::string &shotId); +const Shot *findShotById(const Project &project, const std::string &shotId); + +Shot *selectShotForRender(Project &project, + const std::string &shotId, + bool interactive, + std::istream &input, + std::ostream &output, + std::string &error); + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/Shot.cpp b/tsd/apps/interactive/scivisStudio/Shot.cpp new file mode 100644 index 000000000..a0af2d23f --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/Shot.cpp @@ -0,0 +1,36 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "Shot.h" + +#include + +namespace tsd::scivis_studio::shot { + +DatasetBinding *findDatasetBinding(Shot &shot, const DatasetID &id) +{ + auto itr = std::find_if(shot.datasetBindings.begin(), + shot.datasetBindings.end(), + [&](const DatasetBinding &b) { return b.datasetId == id; }); + return itr == shot.datasetBindings.end() ? nullptr : &*itr; +} + +const DatasetBinding *findDatasetBinding(const Shot &shot, const DatasetID &id) +{ + auto itr = std::find_if(shot.datasetBindings.begin(), + shot.datasetBindings.end(), + [&](const DatasetBinding &b) { return b.datasetId == id; }); + return itr == shot.datasetBindings.end() ? nullptr : &*itr; +} + +void setDatasetBinding(Shot &shot, const DatasetID &id, bool enabled) +{ + if (auto *binding = findDatasetBinding(shot, id)) { + binding->enabled = enabled; + return; + } + + shot.datasetBindings.push_back({id, enabled}); +} + +} // namespace tsd::scivis_studio::shot diff --git a/tsd/apps/interactive/scivisStudio/Shot.h b/tsd/apps/interactive/scivisStudio/Shot.h new file mode 100644 index 000000000..aa8a89856 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/Shot.h @@ -0,0 +1,56 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "Dataset.h" +#include "ShotCameraRig.h" + +#include +#include +#include + +namespace tsd::scivis_studio { + +struct ShotRenderSettings +{ + uint32_t width{1024}; + uint32_t height{768}; + uint32_t samples{128}; + std::string rendererLibrary; + size_t rendererObjectIndex{TSD_INVALID_INDEX}; + std::string rendererSubtype{"default"}; + std::string outputFilePrefix; +}; + +struct DatasetBinding +{ + DatasetID datasetId; + bool enabled{true}; +}; + +struct Shot +{ + ShotID id; + std::string name; + int frameCount{120}; + float fps{24.f}; + int currentFrame{0}; + bool playing{false}; + bool loop{true}; + std::vector datasetBindings; + LightRigID lightRigId; + SceneObjectRef camera; + ShotCameraRig cameraRig; + ShotRenderSettings renderSettings; +}; + +namespace shot { + +DatasetBinding *findDatasetBinding(Shot &shot, const DatasetID &id); +const DatasetBinding *findDatasetBinding(const Shot &shot, const DatasetID &id); +void setDatasetBinding(Shot &shot, const DatasetID &id, bool enabled); + +} // namespace shot + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/ShotCameraRig.cpp b/tsd/apps/interactive/scivisStudio/ShotCameraRig.cpp new file mode 100644 index 000000000..7384cad55 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/ShotCameraRig.cpp @@ -0,0 +1,150 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "ShotCameraRig.h" + +#include "tsd/rendering/view/CameraPath.h" + +#include +#include + +namespace tsd::scivis_studio::shot_camera_rig { + +const char *toString(CameraInterpolation interpolation) +{ + switch (interpolation) { + case CameraInterpolation::Hold: + return "Hold"; + case CameraInterpolation::Linear: + return "Linear"; + case CameraInterpolation::EaseOut: + return "Ease Out"; + case CameraInterpolation::EaseIn: + return "Ease In"; + case CameraInterpolation::EaseOutIn: + return "Ease Out + In"; + } + return "Linear"; +} + +CameraInterpolation interpolationFromString(const std::string &s) +{ + if (s == "Hold") + return CameraInterpolation::Hold; + if (s == "Ease Out") + return CameraInterpolation::EaseOut; + if (s == "Ease In") + return CameraInterpolation::EaseIn; + if (s == "Ease Out + In") + return CameraInterpolation::EaseOutIn; + return CameraInterpolation::Linear; +} + +ManipulatorState manipulatorStateFromManipulator( + const tsd::rendering::Manipulator &m) +{ + ManipulatorState state; + state.orbit.lookat = m.at(); + state.orbit.azeldist = + tsd::math::float3(m.azel().x, m.azel().y, m.distance()); + state.orbit.fixedDist = m.fixedDistance(); + state.orbit.upAxis = static_cast(m.axis()); + state.orbit.mode = static_cast(m.mode()); + return state; +} + +void applyManipulatorState( + tsd::rendering::Manipulator &m, const ManipulatorState &state) +{ + m.setConfig(state.orbit); + m.setFixedDistance(state.orbit.fixedDist); +} + +void sortKeyframes(ShotCameraRig &rig) +{ + std::stable_sort(rig.keyframes.begin(), + rig.keyframes.end(), + [](const CameraKeyframe &a, const CameraKeyframe &b) { + return a.frame < b.frame; + }); +} + +static float lerp(float t, float a, float b) +{ + return a + t * (b - a); +} + +static tsd::math::float3 lerpVec3( + float t, const tsd::math::float3 &a, const tsd::math::float3 &b) +{ + return tsd::math::float3{ + lerp(t, a.x, b.x), lerp(t, a.y, b.y), lerp(t, a.z, b.z)}; +} + +static float applyInterpolation(CameraInterpolation interpolation, float t) +{ + switch (interpolation) { + case CameraInterpolation::Hold: + case CameraInterpolation::Linear: + return t; + case CameraInterpolation::EaseOut: + return t * t; + case CameraInterpolation::EaseIn: + return 1.f - (1.f - t) * (1.f - t) * (1.f - t); + case CameraInterpolation::EaseOutIn: + return t * t * t * (t * (6.f * t - 15.f) + 10.f); + } + return t; +} + +ManipulatorState sampleCameraRig(const ShotCameraRig &rig, int frame) +{ + if (rig.keyframes.empty()) + return rig.current; + + if (rig.keyframes.size() == 1) + return rig.keyframes.front().manipulator; + + auto keyframes = rig.keyframes; + std::stable_sort(keyframes.begin(), + keyframes.end(), + [](const CameraKeyframe &a, const CameraKeyframe &b) { + return a.frame < b.frame; + }); + + if (frame <= keyframes.front().frame) + return keyframes.front().manipulator; + + for (size_t i = 0; i + 1 < keyframes.size(); ++i) { + const auto &a = keyframes[i]; + const auto &b = keyframes[i + 1]; + if (frame > b.frame) + continue; + + if (a.interpolationToNext == CameraInterpolation::Hold + || b.frame == a.frame) + return a.manipulator; + + const float t = static_cast(frame - a.frame) + / static_cast(b.frame - a.frame); + const float interpolatedT = applyInterpolation(a.interpolationToNext, t); + + ManipulatorState out; + out.orbit = a.manipulator.orbit; + out.orbit.lookat = lerpVec3( + interpolatedT, a.manipulator.orbit.lookat, b.manipulator.orbit.lookat); + out.orbit.azeldist = tsd::rendering::lerpAzElDist(interpolatedT, + a.manipulator.orbit.azeldist, + b.manipulator.orbit.azeldist); + out.orbit.fixedDist = lerp(interpolatedT, + a.manipulator.orbit.fixedDist, + b.manipulator.orbit.fixedDist); + out.orbit.upAxis = a.manipulator.orbit.upAxis; + out.orbit.mode = a.manipulator.orbit.mode; + return out; + } + + return keyframes.back().manipulator; +} + +} // namespace tsd::scivis_studio::shot_camera_rig diff --git a/tsd/apps/interactive/scivisStudio/ShotCameraRig.h b/tsd/apps/interactive/scivisStudio/ShotCameraRig.h new file mode 100644 index 000000000..89eb8f4a4 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/ShotCameraRig.h @@ -0,0 +1,56 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/rendering/view/Manipulator.hpp" + +#include +#include + +namespace tsd::scivis_studio { + +enum class CameraInterpolation +{ + Hold, + Linear, + EaseOut, + EaseIn, + EaseOutIn +}; + +struct ManipulatorState +{ + tsd::rendering::CameraPose orbit; +}; + +struct CameraKeyframe +{ + int frame{0}; + std::string name; + ManipulatorState manipulator; + CameraInterpolation interpolationToNext{CameraInterpolation::Linear}; +}; + +struct ShotCameraRig +{ + ManipulatorState current; + std::vector keyframes; +}; + +namespace shot_camera_rig { + +const char *toString(CameraInterpolation interpolation); +CameraInterpolation interpolationFromString(const std::string &s); + +ManipulatorState manipulatorStateFromManipulator( + const tsd::rendering::Manipulator &m); +void applyManipulatorState( + tsd::rendering::Manipulator &m, const ManipulatorState &state); + +void sortKeyframes(ShotCameraRig &rig); +ManipulatorState sampleCameraRig(const ShotCameraRig &rig, int frame); + +} // namespace shot_camera_rig + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/default_ui_layout.txt b/tsd/apps/interactive/scivisStudio/default_ui_layout.txt new file mode 100644 index 000000000..42f546c1a --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/default_ui_layout.txt @@ -0,0 +1,110 @@ +[Window][MainDockSpace] +Pos=0,56 +Size=3840,2206 +Collapsed=0 + +[Window][Project] +Pos=0,56 +Size=872,643 +Collapsed=0 +DockId=0x00000002,0 + +[Window][Dataset Editor] +Pos=0,1628 +Size=872,634 +Collapsed=0 +DockId=0x0000000A,0 + +[Window][Shot Editor] +Pos=0,701 +Size=872,925 +Collapsed=0 +DockId=0x0000000B,0 + +[Window][Light Rig] +Pos=874,1595 +Size=1485,667 +Collapsed=0 +DockId=0x00000003,1 + +[Window][Camera Rig] +Pos=874,1595 +Size=1485,667 +Collapsed=0 +DockId=0x00000003,0 + +[Window][Viewport] +Pos=874,56 +Size=2966,1537 +Collapsed=0 +DockId=0x00000005,0 + +[Window][Object Editor] +Pos=0,1628 +Size=872,634 +Collapsed=0 +DockId=0x0000000A,1 + +[Window][Log] +Pos=2361,1595 +Size=1479,667 +Collapsed=0 +DockId=0x00000004,0 + +[Window][Layers] +Pos=60,60 +Size=420,600 +Collapsed=0 + +[Window][TF Editor] +Pos=80,80 +Size=480,500 +Collapsed=0 + +[Window][Debug##Default] +Pos=60,60 +Size=400,400 +Collapsed=0 + +[Window][##blocking_task_modal] +Pos=1592,1023 +Size=656,216 +Collapsed=0 + +[Window][Add Static Dataset] +Pos=1540,890 +Size=759,242 +Collapsed=0 + +[Table][0xA15CAC7B,2] +Column 0 Weight=1.0000 +Column 1 Weight=1.0000 + +[Table][0x8F307A4E,2] +Column 0 Weight=1.0000 +Column 1 Weight=1.0000 + +[Table][0xB4F9A5A1,2] +Column 0 Weight=1.0000 +Column 1 Weight=1.0000 + +[Table][0xD2378A4E,2] +Column 0 Weight=1.0000 +Column 1 Weight=1.0000 + +[Table][0x092355BE,2] +Column 0 Weight=1.0000 +Column 1 Weight=1.0000 + +[Docking][Data] +DockSpace ID=0x80F5B4C5 Window=0x079D3A04 Pos=0,56 Size=3840,2206 Split=X Selected=0xC450F867 + DockNode ID=0x00000007 Parent=0x80F5B4C5 SizeRef=872,1980 Split=Y Selected=0x9C21DE82 + DockNode ID=0x00000001 Parent=0x00000007 SizeRef=359,1409 Split=Y Selected=0x9C21DE82 + DockNode ID=0x00000002 Parent=0x00000001 SizeRef=359,577 Selected=0x9C21DE82 + DockNode ID=0x0000000B Parent=0x00000001 SizeRef=359,830 Selected=0x5CC9B8E1 + DockNode ID=0x0000000A Parent=0x00000007 SizeRef=359,569 Selected=0x82B4C496 + DockNode ID=0x00000009 Parent=0x80F5B4C5 SizeRef=2966,1980 Split=Y + DockNode ID=0x00000005 Parent=0x00000009 SizeRef=3840,1537 CentralNode=1 Selected=0xC450F867 + DockNode ID=0x00000008 Parent=0x00000009 SizeRef=3840,667 Split=X Selected=0x4192BA76 + DockNode ID=0x00000003 Parent=0x00000008 SizeRef=1485,570 Selected=0x4192BA76 + DockNode ID=0x00000004 Parent=0x00000008 SizeRef=1479,570 Selected=0x139FDA3F diff --git a/tsd/apps/interactive/scivisStudio/modals/AddFileAnimationDatasetDialog.cpp b/tsd/apps/interactive/scivisStudio/modals/AddFileAnimationDatasetDialog.cpp new file mode 100644 index 000000000..c317aa32f --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/modals/AddFileAnimationDatasetDialog.cpp @@ -0,0 +1,362 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "AddFileAnimationDatasetDialog.h" + +#include "tsd/core/Logging.hpp" +#include "tsd/ui/imgui/Application.h" + +#include "imgui.h" + +#include +#include +#include +#include +#include + +namespace tsd::scivis_studio { + +namespace { + +template +void copyToInputBuffer(std::array &buffer, const std::string &value) +{ + buffer.fill('\0'); + std::strncpy(buffer.data(), value.c_str(), buffer.size() - 1); +} + +int naturalCompareString(const std::string &a, const std::string &b) +{ + size_t ia = 0; + size_t ib = 0; + while (ia < a.size() && ib < b.size()) { + const bool digitA = std::isdigit(static_cast(a[ia])); + const bool digitB = std::isdigit(static_cast(b[ib])); + if (digitA && digitB) { + size_t enda = ia; + size_t endb = ib; + while (enda < a.size() + && std::isdigit(static_cast(a[enda]))) + ++enda; + while (endb < b.size() + && std::isdigit(static_cast(b[endb]))) + ++endb; + + auto na = a.substr(ia, enda - ia); + auto nb = b.substr(ib, endb - ib); + na.erase(0, na.find_first_not_of('0')); + nb.erase(0, nb.find_first_not_of('0')); + if (na.size() != nb.size()) + return na.size() < nb.size() ? -1 : 1; + if (na != nb) + return na < nb ? -1 : 1; + ia = enda; + ib = endb; + continue; + } + + if (a[ia] != b[ib]) + return a[ia] < b[ib] ? -1 : 1; + ++ia; + ++ib; + } + + if (ia == a.size() && ib == b.size()) + return 0; + return ia == a.size() ? -1 : 1; +} + +bool naturalPathLess(const std::string &a, const std::string &b) +{ + const auto fa = std::filesystem::path(a).filename().string(); + const auto fb = std::filesystem::path(b).filename().string(); + const int filenameCompare = naturalCompareString(fa, fb); + if (filenameCompare != 0) + return filenameCompare < 0; + return naturalCompareString(a, b) < 0; +} + +std::string trimmedGeneratedName(std::string name) +{ + while (!name.empty()) { + const char c = name.back(); + if (c == ' ' || c == '_' || c == '-' || c == '.') + name.pop_back(); + else + break; + } + return name; +} + +std::string commonStemPrefix(const std::vector &paths) +{ + if (paths.empty()) + return {}; + + std::string prefix = std::filesystem::path(paths.front()).stem().string(); + for (size_t i = 1; i < paths.size() && !prefix.empty(); ++i) { + const auto stem = std::filesystem::path(paths[i]).stem().string(); + size_t n = 0; + while (n < prefix.size() && n < stem.size() && prefix[n] == stem[n]) + ++n; + prefix.resize(n); + } + + prefix = trimmedGeneratedName(prefix); + if (!prefix.empty()) + return prefix; + return std::filesystem::path(paths.front()).stem().string(); +} + +std::string extensionLabel(const std::string &extension) +{ + return extension.empty() ? std::string("") : extension; +} + +bool anySelected(const std::vector &selectedRows) +{ + return std::any_of( + selectedRows.begin(), selectedRows.end(), [](char selected) { + return selected != 0; + }); +} + +void resizeSelection(std::vector &selectedRows, size_t size) +{ + selectedRows.resize(size, 0); +} + +} // namespace + +AddFileAnimationDatasetDialog::AddFileAnimationDatasetDialog( + tsd::ui::imgui::Application *app, ProjectContext *projectContext) + : Modal(app, "Add File Animation Dataset"), + m_projectContext(projectContext) +{} + +AddFileAnimationDatasetDialog::~AddFileAnimationDatasetDialog() = default; + +void AddFileAnimationDatasetDialog::reset() +{ + m_name.fill('\0'); + m_sourcePaths.clear(); + m_browsedSourcePaths.clear(); + m_invalidRows.clear(); + m_selectedRows.clear(); + m_validationMessage.clear(); + m_extensionWarning.clear(); + m_nameEditedByUser = false; + m_showInvalidRows = false; +} + +void AddFileAnimationDatasetDialog::clearValidation() +{ + m_invalidRows.clear(); + m_validationMessage.clear(); + m_extensionWarning.clear(); + m_showInvalidRows = false; +} + +void AddFileAnimationDatasetDialog::appendBrowsedFiles() +{ + if (m_browsedSourcePaths.empty()) + return; + + std::sort( + m_browsedSourcePaths.begin(), m_browsedSourcePaths.end(), naturalPathLess); + m_sourcePaths.insert(m_sourcePaths.end(), + m_browsedSourcePaths.begin(), + m_browsedSourcePaths.end()); + m_browsedSourcePaths.clear(); + resizeSelection(m_selectedRows, m_sourcePaths.size()); + clearValidation(); + updateGeneratedName(); +} + +void AddFileAnimationDatasetDialog::updateGeneratedName() +{ + if (m_nameEditedByUser) + return; + + copyToInputBuffer(m_name, commonStemPrefix(m_sourcePaths)); +} + +bool AddFileAnimationDatasetDialog::validateForImport() +{ + m_invalidRows.assign(m_sourcePaths.size(), false); + m_validationMessage.clear(); + m_extensionWarning.clear(); + + if (m_sourcePaths.empty()) { + m_validationMessage = "Select at least one frame."; + return false; + } + + std::set extensions; + bool ok = true; + for (size_t i = 0; i < m_sourcePaths.size(); ++i) { + const std::filesystem::path path = m_sourcePaths[i]; + extensions.insert(path.extension().string()); + + std::error_code ec; + if (!std::filesystem::exists(path, ec) || ec) { + m_invalidRows[i] = true; + ok = false; + continue; + } + if (!std::filesystem::is_regular_file(path, ec) || ec) { + m_invalidRows[i] = true; + ok = false; + } + } + + if (extensions.size() > 1) { + m_extensionWarning = "Mixed extensions: "; + bool first = true; + for (const auto &ext : extensions) { + if (!first) + m_extensionWarning += ", "; + m_extensionWarning += extensionLabel(ext); + first = false; + } + } + + if (!ok) + m_validationMessage = "One or more selected frames are missing or invalid."; + return ok; +} + +void AddFileAnimationDatasetDialog::buildUI() +{ + appendBrowsedFiles(); + + if (ImGui::InputText("Name", m_name.data(), m_name.size())) + m_nameEditedByUser = true; + + if (ImGui::Button("Add Files...")) + m_app->getFilenamesFromDialog(m_browsedSourcePaths); + ImGui::SameLine(); + if (ImGui::Button("Remove") && anySelected(m_selectedRows)) { + for (int i = static_cast(m_sourcePaths.size()) - 1; i >= 0; --i) { + if (i < static_cast(m_selectedRows.size()) && m_selectedRows[i]) + m_sourcePaths.erase(m_sourcePaths.begin() + i); + } + m_selectedRows.assign(m_sourcePaths.size(), 0); + clearValidation(); + updateGeneratedName(); + } + ImGui::SameLine(); + if (ImGui::Button("Clear")) { + m_sourcePaths.clear(); + m_selectedRows.clear(); + clearValidation(); + updateGeneratedName(); + } + + if (ImGui::Button("Move Up") && anySelected(m_selectedRows) + && !m_selectedRows.front()) { + for (size_t i = 1; i < m_sourcePaths.size(); ++i) { + if (m_selectedRows[i] && !m_selectedRows[i - 1]) { + std::swap(m_sourcePaths[i], m_sourcePaths[i - 1]); + std::swap(m_selectedRows[i], m_selectedRows[i - 1]); + } + } + clearValidation(); + updateGeneratedName(); + } + ImGui::SameLine(); + if (ImGui::Button("Move Down") && anySelected(m_selectedRows) + && !m_selectedRows.empty() && !m_selectedRows.back()) { + for (int i = static_cast(m_sourcePaths.size()) - 2; i >= 0; --i) { + if (m_selectedRows[i] && !m_selectedRows[i + 1]) { + std::swap(m_sourcePaths[i], m_sourcePaths[i + 1]); + std::swap(m_selectedRows[i], m_selectedRows[i + 1]); + } + } + clearValidation(); + updateGeneratedName(); + } + ImGui::SameLine(); + if (ImGui::Button("Sort by Name")) { + std::sort(m_sourcePaths.begin(), m_sourcePaths.end(), naturalPathLess); + m_selectedRows.assign(m_sourcePaths.size(), 0); + clearValidation(); + updateGeneratedName(); + } + + ImGui::Text("Frames: %zu", m_sourcePaths.size()); + if (!m_extensionWarning.empty()) + ImGui::TextWrapped("%s", m_extensionWarning.c_str()); + if (!m_validationMessage.empty()) + ImGui::TextWrapped("%s", m_validationMessage.c_str()); + + if (ImGui::BeginChild( + "FileAnimationFrames", ImVec2(0.f, 240.f), true)) { + resizeSelection(m_selectedRows, m_sourcePaths.size()); + for (int i = 0; i < static_cast(m_sourcePaths.size()); ++i) { + const bool invalid = + m_showInvalidRows && i < static_cast(m_invalidRows.size()) + && m_invalidRows[i]; + if (invalid) + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.f, 0.35f, 0.25f, 1.f)); + + const auto filename = + std::filesystem::path(m_sourcePaths[i]).filename().string(); + const auto label = std::to_string(i) + " " + filename; + if (ImGui::Selectable(label.c_str(), m_selectedRows[i])) { + const bool append = ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeyShift; + if (!append) + m_selectedRows.assign(m_sourcePaths.size(), 0); + m_selectedRows[i] = m_selectedRows[i] ? 0 : 1; + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", m_sourcePaths[i].c_str()); + + if (invalid) + ImGui::PopStyleColor(); + } + } + ImGui::EndChild(); + + ImGui::Spacing(); + if (ImGui::Button("Cancel") || ImGui::IsKeyPressed(ImGuiKey_Escape)) { + reset(); + hide(); + return; + } + + ImGui::SameLine(); + if (ImGui::Button("Import")) { + if (!validateForImport()) { + m_showInvalidRows = true; + tsd::core::logWarning( + "[SciVisStudio] File animation dataset validation failed: %s", + m_validationMessage.c_str()); + return; + } + + if (!m_extensionWarning.empty()) + tsd::core::logWarning( + "[SciVisStudio] %s", m_extensionWarning.c_str()); + + const std::string name = m_name.data(); + std::vector sourcePaths; + sourcePaths.reserve(m_sourcePaths.size()); + for (const auto &path : m_sourcePaths) + sourcePaths.emplace_back(path); + + reset(); + hide(); + m_app->showTaskModal( + [ctx = m_projectContext, name, sourcePaths]() { + if (ctx) { + ctx->addFileAnimationDataset(name, + sourcePaths, + tsd::io::ImporterType::VOLUME_ANIMATION); + } + }, + "Importing File Animation Dataset..."); + } +} + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/modals/AddFileAnimationDatasetDialog.h b/tsd/apps/interactive/scivisStudio/modals/AddFileAnimationDatasetDialog.h new file mode 100644 index 000000000..b571ba0f2 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/modals/AddFileAnimationDatasetDialog.h @@ -0,0 +1,42 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "ProjectContext.h" +#include "tsd/ui/imgui/modals/Modal.h" + +#include +#include +#include +#include + +namespace tsd::scivis_studio { + +struct AddFileAnimationDatasetDialog : public tsd::ui::imgui::Modal +{ + AddFileAnimationDatasetDialog( + tsd::ui::imgui::Application *app, ProjectContext *projectContext); + ~AddFileAnimationDatasetDialog() override; + + private: + void buildUI() override; + void reset(); + void clearValidation(); + void appendBrowsedFiles(); + void updateGeneratedName(); + bool validateForImport(); + + ProjectContext *m_projectContext{nullptr}; + std::array m_name{}; + std::vector m_sourcePaths; + std::vector m_browsedSourcePaths; + std::vector m_invalidRows; + std::vector m_selectedRows; + std::string m_validationMessage; + std::string m_extensionWarning; + bool m_nameEditedByUser{false}; + bool m_showInvalidRows{false}; +}; + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/modals/AddStaticDatasetDialog.cpp b/tsd/apps/interactive/scivisStudio/modals/AddStaticDatasetDialog.cpp new file mode 100644 index 000000000..c8e3b6059 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/modals/AddStaticDatasetDialog.cpp @@ -0,0 +1,125 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "AddStaticDatasetDialog.h" + +#include "tsd/core/Logging.hpp" +#include "tsd/ui/imgui/Application.h" + +#include "imgui.h" + +#include +#include +#include + +namespace tsd::scivis_studio { + +namespace { + +struct ImporterChoice +{ + const char *name; + tsd::io::ImporterType type; +}; + +constexpr std::array IMPORTERS = {{ + {"AGX", tsd::io::ImporterType::AGX}, + {"ASSIMP", tsd::io::ImporterType::ASSIMP}, + {"ASSIMP_FLAT", tsd::io::ImporterType::ASSIMP_FLAT}, + {"AXYZ", tsd::io::ImporterType::AXYZ}, + {"DLAF", tsd::io::ImporterType::DLAF}, + {"E57XYZ", tsd::io::ImporterType::E57XYZ}, + {"ENSIGHT", tsd::io::ImporterType::ENSIGHT}, + {"GLTF", tsd::io::ImporterType::GLTF}, + {"HDRI", tsd::io::ImporterType::HDRI}, + {"HSMESH", tsd::io::ImporterType::HSMESH}, + {"NBODY", tsd::io::ImporterType::NBODY}, + {"OBJ", tsd::io::ImporterType::OBJ}, + {"PDB", tsd::io::ImporterType::PDB}, + {"PLY", tsd::io::ImporterType::PLY}, + {"POINTSBIN_MULTIFILE", tsd::io::ImporterType::POINTSBIN_MULTIFILE}, + {"PT", tsd::io::ImporterType::PT}, + {"SILO", tsd::io::ImporterType::SILO}, + {"SMESH", tsd::io::ImporterType::SMESH}, + {"SWC", tsd::io::ImporterType::SWC}, + {"TRK", tsd::io::ImporterType::TRK}, + {"USD", tsd::io::ImporterType::USD}, + {"VTP", tsd::io::ImporterType::VTP}, + {"VTU", tsd::io::ImporterType::VTU}, + {"XYZDP", tsd::io::ImporterType::XYZDP}, + {"VOLUME", tsd::io::ImporterType::VOLUME}, + {"TSD", tsd::io::ImporterType::TSD}, +}}; + +template +void copyToInputBuffer(std::array &buffer, const std::string &value) +{ + buffer.fill('\0'); + std::strncpy(buffer.data(), value.c_str(), buffer.size() - 1); +} + +} // namespace + +AddStaticDatasetDialog::AddStaticDatasetDialog( + tsd::ui::imgui::Application *app, ProjectContext *projectContext) + : Modal(app, "Add Static Dataset"), m_projectContext(projectContext) +{} + +AddStaticDatasetDialog::~AddStaticDatasetDialog() = default; + +void AddStaticDatasetDialog::buildUI() +{ + ImGui::InputText("Name", m_name.data(), m_name.size()); + + if (!m_browsedSourcePath.empty()) { + copyToInputBuffer(m_sourcePath, m_browsedSourcePath); + m_browsedSourcePath.clear(); + } + + if (ImGui::Button("...##datasetSource")) { + m_browsedSourcePath.clear(); + m_app->getFilenameFromDialog( + m_browsedSourcePath, tsd::ui::imgui::FileDialogMode::OpenFile); + } + ImGui::SameLine(); + ImGui::InputText("Source Path", m_sourcePath.data(), m_sourcePath.size()); + + const char *preview = IMPORTERS[m_selectedImporter].name; + if (ImGui::BeginCombo("Importer", preview)) { + for (int i = 0; i < static_cast(IMPORTERS.size()); ++i) { + const bool selected = i == m_selectedImporter; + if (ImGui::Selectable(IMPORTERS[i].name, selected)) + m_selectedImporter = i; + if (selected) + ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + + ImGui::Spacing(); + if (ImGui::Button("Cancel") || ImGui::IsKeyPressed(ImGuiKey_Escape)) { + hide(); + return; + } + + ImGui::SameLine(); + if (ImGui::Button("Import")) { + const std::string name = m_name.data(); + const std::filesystem::path sourcePath = m_sourcePath.data(); + const auto importer = IMPORTERS[m_selectedImporter].type; + if (sourcePath.empty()) { + tsd::core::logWarning("[SciVisStudio] Dataset source path is empty"); + return; + } + + hide(); + m_app->showTaskModal( + [ctx = m_projectContext, name, sourcePath, importer]() { + if (ctx) + ctx->addStaticDataset(name, sourcePath, importer); + }, + "Importing Dataset..."); + } +} + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/modals/AddStaticDatasetDialog.h b/tsd/apps/interactive/scivisStudio/modals/AddStaticDatasetDialog.h new file mode 100644 index 000000000..11dd8dcad --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/modals/AddStaticDatasetDialog.h @@ -0,0 +1,30 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "ProjectContext.h" +#include "tsd/ui/imgui/modals/Modal.h" + +#include +#include + +namespace tsd::scivis_studio { + +struct AddStaticDatasetDialog : public tsd::ui::imgui::Modal +{ + AddStaticDatasetDialog( + tsd::ui::imgui::Application *app, ProjectContext *projectContext); + ~AddStaticDatasetDialog() override; + + private: + void buildUI() override; + + ProjectContext *m_projectContext{nullptr}; + std::array m_name{}; + std::array m_sourcePath{}; + std::string m_browsedSourcePath; + int m_selectedImporter{0}; +}; + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/modals/ProjectLocationDialog.cpp b/tsd/apps/interactive/scivisStudio/modals/ProjectLocationDialog.cpp new file mode 100644 index 000000000..4a03988c5 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/modals/ProjectLocationDialog.cpp @@ -0,0 +1,113 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "ProjectLocationDialog.h" + +#include "ProjectSerialization.h" + +#include "tsd/ui/imgui/Application.h" + +#include "imgui.h" + +#include + +namespace tsd::scivis_studio { + +namespace { + +template +void copyToInputBuffer(std::array &buffer, const std::string &value) +{ + buffer.fill('\0'); + std::strncpy(buffer.data(), value.c_str(), buffer.size() - 1); +} + +} // namespace + +ProjectLocationDialog::ProjectLocationDialog(tsd::ui::imgui::Application *app) + : Modal(app, "Project Location") +{} + +ProjectLocationDialog::~ProjectLocationDialog() = default; + +void ProjectLocationDialog::configure(ProjectLocationMode mode, + std::function onAccept) +{ + m_mode = mode; + m_onAccept = std::move(onAccept); + m_error.clear(); +} + +bool ProjectLocationDialog::validate( + const std::filesystem::path &path, std::string &error) const +{ + if (path.empty()) { + error = "Enter a project directory."; + return false; + } + + const auto manifest = path / PROJECT_MANIFEST_FILENAME; + if (m_mode == ProjectLocationMode::OpenProject) { + auto result = validateProjectRoot(path); + if (!result.ok) + error = result.error; + return result.ok; + } + + if (std::filesystem::exists(manifest)) { + error = "Target directory already contains project.tsd."; + return false; + } + + if (std::filesystem::exists(path) && !std::filesystem::is_directory(path)) { + error = "Target path is not a directory."; + return false; + } + + return true; +} + +void ProjectLocationDialog::buildUI() +{ + const char *title = "Open Project"; + const char *button = "Open"; + if (m_mode == ProjectLocationMode::SaveProjectAs) { + title = "Save Project As"; + button = "Save"; + } + + ImGui::TextUnformatted(title); + + if (!m_browsedDirectory.empty()) { + copyToInputBuffer(m_directory, m_browsedDirectory); + m_browsedDirectory.clear(); + } + + if (ImGui::Button("...##projectDirectory")) { + m_browsedDirectory.clear(); + m_app->getFilenameFromDialog( + m_browsedDirectory, tsd::ui::imgui::FileDialogMode::OpenDirectory); + } + ImGui::SameLine(); + ImGui::InputText("Directory", m_directory.data(), m_directory.size()); + if (!m_error.empty()) + ImGui::TextColored(ImVec4(1.f, 0.35f, 0.25f, 1.f), "%s", m_error.c_str()); + + ImGui::Spacing(); + if (ImGui::Button("Cancel") || ImGui::IsKeyPressed(ImGuiKey_Escape)) { + hide(); + return; + } + + ImGui::SameLine(); + if (ImGui::Button(button)) { + std::filesystem::path path(m_directory.data()); + if (!validate(path, m_error)) + return; + hide(); + if (m_onAccept) + m_onAccept(path); + } +} + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/modals/ProjectLocationDialog.h b/tsd/apps/interactive/scivisStudio/modals/ProjectLocationDialog.h new file mode 100644 index 000000000..2d4555f74 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/modals/ProjectLocationDialog.h @@ -0,0 +1,40 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/ui/imgui/modals/Modal.h" + +#include +#include +#include +#include + +namespace tsd::scivis_studio { + +enum class ProjectLocationMode +{ + OpenProject, + SaveProjectAs +}; + +struct ProjectLocationDialog : public tsd::ui::imgui::Modal +{ + explicit ProjectLocationDialog(tsd::ui::imgui::Application *app); + ~ProjectLocationDialog() override; + + void configure(ProjectLocationMode mode, + std::function onAccept); + + private: + void buildUI() override; + bool validate(const std::filesystem::path &path, std::string &error) const; + + ProjectLocationMode m_mode{ProjectLocationMode::OpenProject}; + std::function m_onAccept; + std::array m_directory{}; + std::string m_browsedDirectory; + std::string m_error; +}; + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/scivisStudio.cpp b/tsd/apps/interactive/scivisStudio/scivisStudio.cpp new file mode 100644 index 000000000..2f71e151d --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/scivisStudio.cpp @@ -0,0 +1,11 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "Application.h" + +int main(int argc, const char **argv) +{ + tsd::scivis_studio::Application app(argc, argv); + app.run(1920, 1080, "SciVis Studio"); + return 0; +} diff --git a/tsd/apps/interactive/scivisStudio/scivisStudioRenderShot.cpp b/tsd/apps/interactive/scivisStudio/scivisStudioRenderShot.cpp new file mode 100644 index 000000000..18a1b2106 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/scivisStudioRenderShot.cpp @@ -0,0 +1,105 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "ProjectContext.h" +#include "RenderShot.h" +#include "RenderShotCLI.h" + +#include "tsd/app/Context.h" + +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#define TSD_ISATTY _isatty +#define TSD_FILENO _fileno +#else +#include +#define TSD_ISATTY isatty +#define TSD_FILENO fileno +#endif + +namespace { + +volatile std::sig_atomic_t g_canceled = 0; + +void handleInterrupt(int) +{ + g_canceled = 1; +} + +} // namespace + +int main(int argc, const char **argv) +{ + using namespace tsd::scivis_studio; + + std::vector args(argv, argv + argc); + const auto programName = args.empty() ? "scivisStudioRenderShot" : args[0]; + + RenderShotCommandLine commandLine; + std::string error; + if (!parseRenderShotCommandLine(args, commandLine, error)) { + std::cerr << error << '\n' << renderShotUsage(programName); + return 1; + } + + if (commandLine.showHelp) { + std::cout << renderShotUsage(programName); + return 0; + } + + tsd::app::Context appContext; + ProjectContext projectContext(&appContext); + if (!projectContext.openProject( + commandLine.projectDirectory, nullptr, nullptr, nullptr, &error)) { + std::cerr << "failed to open project: " << error << '\n'; + return 1; + } + + const bool interactive = TSD_ISATTY(TSD_FILENO(stdin)) != 0; + auto *shot = selectShotForRender(projectContext.project(), + commandLine.shotId, + interactive, + std::cin, + std::cout, + error); + if (!shot) { + std::cerr << error << '\n'; + return 1; + } + + projectContext.project().activeShotId = shot->id; + projectContext.syncAnimationManagerToActiveShot(); + projectContext.applyActiveShot(); + + std::signal(SIGINT, handleInterrupt); + + std::cout << "Rendering shot " << shot->id << " \"" << shot->name + << "\" from " << commandLine.projectDirectory.string() << '\n'; + + RenderShotProgress progress; + progress.onFrame = [](int frame, int totalFrames) { + if (g_canceled) + return false; + + std::cout << "Frame " << frame + 1 << " / " << totalFrames << '\n'; + return true; + }; + + const bool completed = renderActiveShotToFrames(projectContext, &progress); + if (!completed) { + if (g_canceled) + std::cerr << "Canceled\n"; + else + std::cerr << "Render failed\n"; + return 1; + } + + std::cout << "Done\n"; + return 0; +} diff --git a/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp b/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp new file mode 100644 index 000000000..78cbf3930 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp @@ -0,0 +1,206 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "CameraRigEditor.h" + +#include "tsd/rendering/view/ManipulatorToTSD.hpp" +#include "tsd/scene/objects/Camera.hpp" +#include "tsd/ui/imgui/tsd_ui_imgui.h" + +#include "imgui.h" + +#include + +namespace tsd::scivis_studio { + + +namespace { + +int cameraInterpolationIndex(CameraInterpolation interpolation) +{ + switch (interpolation) { + case CameraInterpolation::Hold: + return 0; + case CameraInterpolation::Linear: + return 1; + case CameraInterpolation::EaseOut: + return 2; + case CameraInterpolation::EaseIn: + return 3; + case CameraInterpolation::EaseOutIn: + return 4; + } + return 1; +} + +CameraInterpolation cameraInterpolationFromIndex(int index) +{ + switch (index) { + case 0: + return CameraInterpolation::Hold; + case 2: + return CameraInterpolation::EaseOut; + case 3: + return CameraInterpolation::EaseIn; + case 4: + return CameraInterpolation::EaseOutIn; + default: + return CameraInterpolation::Linear; + } +} + +} // namespace + +CameraRigEditor::CameraRigEditor( + tsd::ui::imgui::Application *app, ProjectContext *projectContext) + : Window(app, "Camera Rig"), m_projectContext(projectContext) +{} + +CameraRigEditor::~CameraRigEditor() = default; + +void CameraRigEditor::buildUI() +{ + if (!m_projectContext) + return; + + auto &project = m_projectContext->project(); + auto *shot = project::activeShot(project); + if (!shot) { + ImGui::TextDisabled("No active shot"); + return; + } + + auto *ctx = m_projectContext->appContext(); + auto &rig = shot->cameraRig; + + if (ImGui::Button("Set View")) { + rig.current = shot_camera_rig::manipulatorStateFromManipulator(ctx->view.manipulator); + project.markDirty(); + } + tsd::ui::tooltipForPreviousItem("Set Rig View From Viewport"); + + ImGui::SameLine(); + + if (ImGui::Button("Capture")) { + CameraKeyframe keyframe; + keyframe.frame = shot->currentFrame; + keyframe.name = "Frame " + std::to_string(shot->currentFrame); + keyframe.manipulator = + shot_camera_rig::manipulatorStateFromManipulator(ctx->view.manipulator); + rig.keyframes.push_back(std::move(keyframe)); + shot_camera_rig::sortKeyframes(rig); + m_selectedKeyframe = static_cast(rig.keyframes.size()) - 1; + project.markDirty(); + } + tsd::ui::tooltipForPreviousItem("Capture Keyframe At Current Frame"); + + ImGui::SameLine(); + + if (m_selectedKeyframe >= static_cast(rig.keyframes.size())) + m_selectedKeyframe = rig.keyframes.empty() ? -1 : 0; + + const bool hasSelection = m_selectedKeyframe >= 0 + && m_selectedKeyframe < static_cast(rig.keyframes.size()); + + ImGui::BeginDisabled(!hasSelection); + if (ImGui::Button("Update")) { + rig.keyframes[m_selectedKeyframe].manipulator = + shot_camera_rig::manipulatorStateFromManipulator(ctx->view.manipulator); + project.markDirty(); + } + tsd::ui::tooltipForPreviousItem("Update Selected From Viewport"); + ImGui::SameLine(); + if (ImGui::Button("Jump")) { + shot->currentFrame = rig.keyframes[m_selectedKeyframe].frame; + if (ctx) + ctx->tsd.animationMgr.setAnimationFrame(shot->currentFrame); + else + m_projectContext->applyActiveShot(); + } + tsd::ui::tooltipForPreviousItem("Jump Viewport To Keyframe"); + ImGui::SameLine(); + if (ImGui::Button("Delete")) { + rig.keyframes.erase(rig.keyframes.begin() + m_selectedKeyframe); + m_selectedKeyframe = -1; + project.markDirty(); + } + tsd::ui::tooltipForPreviousItem("Delete Keyframe"); + ImGui::EndDisabled(); + + if (ImGui::BeginTable( + "keyframes", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { + ImGui::TableSetupColumn( + "", ImGuiTableColumnFlags_WidthFixed, ImGui::GetFrameHeight()); + ImGui::TableSetupColumn("Frame"); + ImGui::TableSetupColumn("Name"); + ImGui::TableSetupColumn("Interpolation"); + ImGui::TableSetupColumn("Pose"); + ImGui::TableHeadersRow(); + + for (int i = 0; i < static_cast(rig.keyframes.size()); ++i) { + auto &keyframe = rig.keyframes[i]; + ImGui::PushID(i); + ImGui::TableNextRow(); + if (m_selectedKeyframe == i) { + const ImU32 selectedColor = ImGui::GetColorU32(ImGuiCol_Header); + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, selectedColor); + } + + ImGui::TableNextColumn(); + if (ImGui::RadioButton("##selected", m_selectedKeyframe == i)) + m_selectedKeyframe = i; + tsd::ui::tooltipForPreviousItem( + "Select Keyframe; Double-Click To Jump Viewport To Keyframe"); + if (ImGui::IsItemHovered() + && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + m_selectedKeyframe = i; + shot->currentFrame = keyframe.frame; + if (ctx) + ctx->tsd.animationMgr.setAnimationFrame(shot->currentFrame); + else + m_projectContext->applyActiveShot(); + } + + ImGui::TableNextColumn(); + if (ImGui::InputInt("##frame", &keyframe.frame)) { + shot_camera_rig::sortKeyframes(rig); + project.markDirty(); + } + + ImGui::TableNextColumn(); + char name[256]{}; + std::snprintf(name, sizeof(name), "%s", keyframe.name.c_str()); + if (ImGui::InputText("##name", name, sizeof(name))) { + keyframe.name = name; + project.markDirty(); + } + + ImGui::TableNextColumn(); + int interpolation = + cameraInterpolationIndex(keyframe.interpolationToNext); + const char *items[] = { + "Hold", "Linear", "Ease Out", "Ease In", "Ease Out + In"}; + if (ImGui::Combo("##interp", &interpolation, items, 5)) { + keyframe.interpolationToNext = + cameraInterpolationFromIndex(interpolation); + project.markDirty(); + } + + ImGui::TableNextColumn(); + const auto &pose = keyframe.manipulator.orbit; + ImGui::Text( + "%.2f %.2f %.2f", pose.azeldist.x, pose.azeldist.y, pose.azeldist.z); + ImGui::PopID(); + } + + ImGui::EndTable(); + } + + if (hasSelection && ImGui::IsWindowHovered() + && ImGui::IsMouseClicked(ImGuiMouseButton_Left) + && !ImGui::IsAnyItemHovered()) { + m_selectedKeyframe = -1; + } +} + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.h b/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.h new file mode 100644 index 000000000..fec56d9f2 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.h @@ -0,0 +1,24 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "ProjectContext.h" +#include "tsd/ui/imgui/windows/Window.h" + +namespace tsd::scivis_studio { + +struct CameraRigEditor : public tsd::ui::imgui::Window +{ + CameraRigEditor( + tsd::ui::imgui::Application *app, ProjectContext *projectContext); + ~CameraRigEditor() override; + + void buildUI() override; + + private: + ProjectContext *m_projectContext{nullptr}; + int m_selectedKeyframe{-1}; +}; + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/windows/DatasetEditor.cpp b/tsd/apps/interactive/scivisStudio/windows/DatasetEditor.cpp new file mode 100644 index 000000000..62f93ebb4 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/windows/DatasetEditor.cpp @@ -0,0 +1,85 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "DatasetEditor.h" + +#include "imgui.h" + +#include + +namespace tsd::scivis_studio { + +DatasetEditor::DatasetEditor( + tsd::ui::imgui::Application *app, ProjectContext *projectContext) + : Window(app, "Dataset Editor"), m_projectContext(projectContext) +{} + +DatasetEditor::~DatasetEditor() = default; + +void DatasetEditor::buildUI() +{ + if (!m_projectContext) + return; + + auto &datasets = m_projectContext->project().datasets; + if (datasets.empty()) { + ImGui::TextDisabled("No dataset selected"); + return; + } + + if (m_selectedDataset >= static_cast(datasets.size())) + m_selectedDataset = 0; + + const char *preview = datasets[m_selectedDataset].name.c_str(); + if (ImGui::BeginCombo("Dataset", preview)) { + for (int i = 0; i < static_cast(datasets.size()); ++i) { + const bool selected = i == m_selectedDataset; + if (ImGui::Selectable(datasets[i].name.c_str(), selected)) + m_selectedDataset = i; + if (selected) + ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + + auto &dataset = datasets[m_selectedDataset]; + ImGui::Text("ID: %s", dataset.id.c_str()); + ImGui::Text("Status: %s", dataset::toString(dataset.status)); + ImGui::Text("Source kind: %s", dataset::toString(dataset.sourceKind)); + ImGui::Text("Importer: %s", dataset.importerType.c_str()); + ImGui::TextWrapped("Path: %s", dataset.source.absolutePath.c_str()); + if (dataset.sourceKind == DatasetSourceKind::TimeSeries) { + ImGui::Text("Frames: %zu", dataset.sourceFiles.size()); + ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_SpanAvailWidth; + if (dataset.sourceFiles.size() <= 12) + flags |= ImGuiTreeNodeFlags_DefaultOpen; + const auto label = + "Source Files (" + std::to_string(dataset.sourceFiles.size()) + ")"; + if (ImGui::TreeNodeEx(label.c_str(), flags)) { + for (size_t i = 0; i < dataset.sourceFiles.size(); ++i) { + const auto &sourceFile = dataset.sourceFiles[i]; + const auto path = m_projectContext->resolveSourceFilePath(sourceFile); + const bool regular = + m_projectContext->sourceFileIsRegular(sourceFile); + const auto filename = path.empty() + ? std::filesystem::path(sourceFile.absolutePath).filename().string() + : path.filename().string(); + const auto row = std::to_string(i) + " " + filename; + if (!regular) + ImGui::PushStyleColor( + ImGuiCol_Text, ImVec4(1.f, 0.35f, 0.25f, 1.f)); + ImGui::TextUnformatted(row.c_str()); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", path.string().c_str()); + if (!regular) + ImGui::PopStyleColor(); + } + ImGui::TreePop(); + } + } + ImGui::Text("Root: %s/%zu", + dataset.rootNode.layerName.c_str(), + dataset.rootNode.nodeIndex); +} + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/windows/DatasetEditor.h b/tsd/apps/interactive/scivisStudio/windows/DatasetEditor.h new file mode 100644 index 000000000..98784a366 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/windows/DatasetEditor.h @@ -0,0 +1,24 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "ProjectContext.h" +#include "tsd/ui/imgui/windows/Window.h" + +namespace tsd::scivis_studio { + +struct DatasetEditor : public tsd::ui::imgui::Window +{ + DatasetEditor( + tsd::ui::imgui::Application *app, ProjectContext *projectContext); + ~DatasetEditor() override; + + void buildUI() override; + + private: + ProjectContext *m_projectContext{nullptr}; + int m_selectedDataset{0}; +}; + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/windows/LightRigEditor.cpp b/tsd/apps/interactive/scivisStudio/windows/LightRigEditor.cpp new file mode 100644 index 000000000..2fda290c9 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/windows/LightRigEditor.cpp @@ -0,0 +1,295 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "LightRigEditor.h" + +#include "imgui.h" +#include "tsd/app/Context.h" + +#include +#include +#include +#include +#include +#include + +namespace tsd::scivis_studio { + + +namespace { + +struct LightTypeOption +{ + const char *label; + const char *subtype; +}; + +constexpr std::array LIGHT_TYPES = { + {{"Directional", "directional"}, + {"Point", "point"}, + {"Quad", "quad"}, + {"Spot", "spot"}, + {"Ring", "ring"}}}; + +std::vector lightNodes(tsd::scene::LayerNodeRef root) +{ + std::vector nodes; + if (!root) + return nodes; + + auto *layer = (*root)->layer(); + layer->traverse(root, [&](auto &node, int level) { + if (level > 0 && node->isObject() && node->type() == ANARI_LIGHT) + nodes.push_back(layer->at(node.index())); + return true; + }); + return nodes; +} + +std::string lightName(tsd::scene::LayerNodeRef node) +{ + if (!node) + return ""; + + auto *object = (*node)->getObject(); + std::string label = (*node)->name(); + if (label.empty() && object) + label = object->name(); + if (label.empty()) + label = "Light"; + return label; +} + +std::string lightSubtype(tsd::scene::LayerNodeRef node) +{ + if (!node) + return ""; + + auto *object = (*node)->getObject(); + if (object) + return object->subtype().str(); + return ""; +} + +} // namespace + +LightRigEditor::LightRigEditor( + tsd::ui::imgui::Application *app, ProjectContext *projectContext) + : Window(app, "Light Rig"), m_projectContext(projectContext) +{} + +LightRigEditor::~LightRigEditor() = default; + +bool LightRigEditor::inputText( + const char *label, std::string &value, size_t capacity) +{ + std::vector buffer(capacity, '\0'); + std::strncpy(buffer.data(), value.c_str(), buffer.size() - 1); + if (ImGui::InputText(label, buffer.data(), buffer.size())) { + value = buffer.data(); + return true; + } + return false; +} + +void LightRigEditor::buildUI_addLight(LightRig &rig) +{ + if (ImGui::Button("Add Light")) + ImGui::OpenPopup("Add Light"); + + if (ImGui::BeginPopup("Add Light")) { + for (const auto &type : LIGHT_TYPES) { + if (ImGui::MenuItem(type.label)) { + auto node = m_projectContext->addLightToRig(rig, type.subtype); + if (node) + appContext()->setSelected(node); + } + } + ImGui::EndPopup(); + } +} + +void LightRigEditor::buildUI_lightList(LightRig &rig) +{ + auto root = m_projectContext->resolveLightRigRoot(rig); + auto nodes = lightNodes(root); + if (m_selectedLight >= static_cast(nodes.size())) + m_selectedLight = nodes.empty() ? -1 : 0; + + ImGui::SeparatorText("Lights"); + buildUI_addLight(rig); + + if (nodes.empty()) { + ImGui::TextDisabled("No lights"); + } else { + const ImGuiTableFlags flags = ImGuiTableFlags_Borders + | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp; + if (ImGui::BeginTable("lights", 3, flags)) { + ImGui::TableSetupColumn( + "", ImGuiTableColumnFlags_WidthFixed, ImGui::GetFrameHeight()); + ImGui::TableSetupColumn("Name"); + ImGui::TableSetupColumn("Type"); + ImGui::TableHeadersRow(); + + for (int i = 0; i < static_cast(nodes.size()); ++i) { + const bool selected = i == m_selectedLight; + ImGui::PushID(i); + ImGui::TableNextRow(); + if (selected) { + const ImU32 selectedColor = ImGui::GetColorU32(ImGuiCol_Header); + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, selectedColor); + } + + ImGui::TableNextColumn(); + if (ImGui::RadioButton("##selected", selected)) { + m_selectedLight = i; + appContext()->setSelected(nodes[i]); + } + + ImGui::TableNextColumn(); + const auto name = lightName(nodes[i]); + ImGui::TextUnformatted(name.c_str()); + if (selected) + ImGui::SetItemDefaultFocus(); + + ImGui::TableNextColumn(); + const auto subtype = lightSubtype(nodes[i]); + ImGui::TextUnformatted(subtype.c_str()); + ImGui::PopID(); + } + + ImGui::EndTable(); + } + } + + const bool hasSelection = + m_selectedLight >= 0 && m_selectedLight < static_cast(nodes.size()); + auto selectedNode = + hasSelection ? nodes[m_selectedLight] : tsd::scene::LayerNodeRef{}; + ImGui::BeginDisabled(!hasSelection); + if (ImGui::Button("Rename Selected") && hasSelection) { + auto *object = (*selectedNode)->getObject(); + m_renameLightName = (*selectedNode)->name(); + if (m_renameLightName.empty() && object) + m_renameLightName = object->name(); + ImGui::OpenPopup("Rename Light"); + } + ImGui::SameLine(); + if (ImGui::Button("Remove Selected") && hasSelection) { + m_projectContext->removeLightFromRig(rig, nodes[m_selectedLight]); + m_selectedLight = -1; + } + ImGui::EndDisabled(); + + ImGui::SetNextWindowSize(ImVec2(800.f, 0.f), ImGuiCond_Appearing); + if (ImGui::BeginPopupModal( + "Rename Light", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::Text("Light Name"); + ImGui::SetNextItemWidth(-FLT_MIN); + if (ImGui::IsWindowAppearing()) + ImGui::SetKeyboardFocusHere(); + inputText("##lightName", m_renameLightName); + + ImGui::BeginDisabled(m_renameLightName.empty() || !selectedNode); + if (ImGui::Button("Rename") && selectedNode) { + (*selectedNode)->name() = m_renameLightName; + if (auto *object = (*selectedNode)->getObject()) + object->setName(m_renameLightName); + m_projectContext->project().markDirty(); + ImGui::CloseCurrentPopup(); + } + ImGui::EndDisabled(); + ImGui::SameLine(); + if (ImGui::Button("Cancel")) + ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } +} + +void LightRigEditor::buildUI() +{ + if (!m_projectContext) + return; + + auto &project = m_projectContext->project(); + auto &rigs = project.lightRigs; + + if (ImGui::Button("Add Rig")) { + if (auto *rig = m_projectContext->createLightRig()) + m_selectedRig = static_cast(rigs.size()) - 1; + } + + if (rigs.empty()) { + ImGui::TextDisabled("No light rigs"); + return; + } + + if (m_selectedRig >= static_cast(rigs.size())) + m_selectedRig = 0; + + const char *preview = rigs[m_selectedRig].name.c_str(); + if (ImGui::BeginCombo("Rig", preview)) { + for (int i = 0; i < static_cast(rigs.size()); ++i) { + const bool selected = i == m_selectedRig; + if (ImGui::Selectable(rigs[i].name.c_str(), selected)) { + m_selectedRig = i; + m_selectedLight = 0; + } + if (selected) + ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + + auto &rig = rigs[m_selectedRig]; + if (inputText("Name", rig.name)) + project.markDirty(); + + auto *shot = project::activeShot(project); + const bool activeShotUsesRig = shot && shot->lightRigId == rig.id; + ImGui::BeginDisabled(!shot || activeShotUsesRig); + if (ImGui::Button("Use for Active Shot") && shot) { + shot->lightRigId = rig.id; + project.markDirty(); + m_projectContext->applyActiveShot(); + } + ImGui::EndDisabled(); + + ImGui::SameLine(); + if (ImGui::Button("Remove Rig")) { + if (m_projectContext->shotUseCount(rig.id) > 0) { + m_pendingDeleteRig = rig.id; + ImGui::OpenPopup("Delete Light Rig?"); + } else { + m_projectContext->removeLightRig(rig.id); + m_selectedRig = 0; + return; + } + } + + if (ImGui::BeginPopupModal( + "Delete Light Rig?", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + auto *pending = project::findLightRig(project, m_pendingDeleteRig); + const int useCount = m_projectContext->shotUseCount(m_pendingDeleteRig); + ImGui::Text("Delete '%s' and clear %d shot reference%s?", + pending ? pending->name.c_str() : m_pendingDeleteRig.c_str(), + useCount, + useCount == 1 ? "" : "s"); + if (ImGui::Button("Delete")) { + m_projectContext->removeLightRig(m_pendingDeleteRig); + m_pendingDeleteRig.clear(); + m_selectedRig = 0; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) { + m_pendingDeleteRig.clear(); + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + + buildUI_lightList(rig); +} + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/windows/LightRigEditor.h b/tsd/apps/interactive/scivisStudio/windows/LightRigEditor.h new file mode 100644 index 000000000..0fccc14bb --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/windows/LightRigEditor.h @@ -0,0 +1,33 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "ProjectContext.h" +#include "tsd/ui/imgui/windows/Window.h" + +#include + +namespace tsd::scivis_studio { + +struct LightRigEditor : public tsd::ui::imgui::Window +{ + LightRigEditor( + tsd::ui::imgui::Application *app, ProjectContext *projectContext); + ~LightRigEditor() override; + + void buildUI() override; + + private: + bool inputText(const char *label, std::string &value, size_t capacity = 512); + void buildUI_lightList(LightRig &rig); + void buildUI_addLight(LightRig &rig); + + ProjectContext *m_projectContext{nullptr}; + int m_selectedRig{0}; + int m_selectedLight{0}; + std::string m_renameLightName; + LightRigID m_pendingDeleteRig; +}; + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/windows/ProjectWindow.cpp b/tsd/apps/interactive/scivisStudio/windows/ProjectWindow.cpp new file mode 100644 index 000000000..1474d5998 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/windows/ProjectWindow.cpp @@ -0,0 +1,50 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "ProjectWindow.h" + +#include "imgui.h" + +namespace tsd::scivis_studio { + +ProjectWindow::ProjectWindow( + tsd::ui::imgui::Application *app, ProjectContext *projectContext) + : Window(app, "Project"), m_projectContext(projectContext) +{} + +ProjectWindow::~ProjectWindow() = default; + +void ProjectWindow::buildUI() +{ + if (!m_projectContext) + return; + + auto &project = m_projectContext->project(); + ImGui::Text("Name: %s", project.name.c_str()); + ImGui::Text("Path: %s", + project.projectDirectory.empty() + ? "{unsaved}" + : project.projectDirectory.string().c_str()); + ImGui::Text("Status: %s", project.dirty ? "dirty" : "clean"); + + ImGui::SeparatorText("Datasets"); + if (project.datasets.empty()) + ImGui::TextDisabled("No datasets"); + for (const auto &dataset : project.datasets) + ImGui::BulletText( + "%s [%s]", + dataset.name.c_str(), + dataset::toString(dataset.status)); + + ImGui::SeparatorText("Shots"); + for (auto &shot : project.shots) { + const bool selected = shot.id == project.activeShotId; + if (ImGui::Selectable(shot.name.c_str(), selected)) { + project.activeShotId = shot.id; + m_projectContext->syncAnimationManagerToActiveShot(); + m_projectContext->applyActiveShot(); + } + } +} + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/windows/ProjectWindow.h b/tsd/apps/interactive/scivisStudio/windows/ProjectWindow.h new file mode 100644 index 000000000..61cc305e5 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/windows/ProjectWindow.h @@ -0,0 +1,23 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "ProjectContext.h" +#include "tsd/ui/imgui/windows/Window.h" + +namespace tsd::scivis_studio { + +struct ProjectWindow : public tsd::ui::imgui::Window +{ + ProjectWindow( + tsd::ui::imgui::Application *app, ProjectContext *projectContext); + ~ProjectWindow() override; + + void buildUI() override; + + private: + ProjectContext *m_projectContext{nullptr}; +}; + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/windows/ShotEditor.cpp b/tsd/apps/interactive/scivisStudio/windows/ShotEditor.cpp new file mode 100644 index 000000000..1615af696 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/windows/ShotEditor.cpp @@ -0,0 +1,301 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "ShotEditor.h" + +#include "imgui.h" +#include "tsd/app/Context.h" +#include "tsd/core/Logging.hpp" +#include "tsd/scene/objects/Renderer.hpp" + +#include +#include +#include +#include + +namespace tsd::scivis_studio { + + +namespace { + +constexpr const char *NO_RENDERERS_LABEL = ""; + +std::string rendererLabel(const tsd::scene::Renderer &renderer) +{ + std::string label = renderer.name(); + if (label.empty()) + label = renderer.subtype().str(); + label += " [" + std::to_string(renderer.index()) + "]"; + return label; +} + +} // namespace + +ShotEditor::ShotEditor(tsd::ui::imgui::Application *app, + ProjectContext *projectContext, + std::function onRender) + : Window(app, "Shot Editor"), + m_projectContext(projectContext), + m_onRender(std::move(onRender)) +{} + +ShotEditor::~ShotEditor() = default; + +bool ShotEditor::inputText( + const char *label, std::string &value, size_t capacity) +{ + std::vector buffer(capacity, '\0'); + std::strncpy(buffer.data(), value.c_str(), buffer.size() - 1); + if (ImGui::InputText(label, buffer.data(), buffer.size())) { + value = buffer.data(); + return true; + } + return false; +} + +void ShotEditor::buildUI_deviceSelector(Shot &shot) +{ + auto *ctx = m_projectContext ? m_projectContext->appContext() : nullptr; + auto &project = m_projectContext->project(); + auto &settings = shot.renderSettings; + const auto preview = settings.rendererLibrary.empty() + ? std::string{""} + : settings.rendererLibrary; + + if (!ctx) { + ImGui::BeginDisabled(); + if (ImGui::BeginCombo("Device", preview.c_str())) + ImGui::EndCombo(); + ImGui::EndDisabled(); + return; + } + + if (ImGui::BeginCombo("Device", preview.c_str())) { + for (const auto &libName : ctx->anari.libraryList()) { + const bool selected = settings.rendererLibrary == libName; + if (ImGui::Selectable(libName.c_str(), selected)) { + if (settings.rendererLibrary != libName) { + settings.rendererLibrary = libName; + settings.rendererObjectIndex = TSD_INVALID_INDEX; + settings.rendererSubtype = "default"; + m_rendererLoadAttemptedLibrary.clear(); + project.markDirty(); + } + } + if (selected) + ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } +} + +void ShotEditor::buildUI_rendererSelector(Shot &shot) +{ + auto *ctx = m_projectContext ? m_projectContext->appContext() : nullptr; + auto &project = m_projectContext->project(); + auto &settings = shot.renderSettings; + std::vector renderers; + + if (ctx && ctx->anari.isLoadableLibrary(settings.rendererLibrary)) { + auto &scene = ctx->tsd.scene; + renderers = scene.renderersOfDevice(settings.rendererLibrary); + if (renderers.empty() + && m_rendererLoadAttemptedLibrary != settings.rendererLibrary) { + m_rendererLoadAttemptedLibrary = settings.rendererLibrary; + if (auto device = ctx->anari.loadDevice(settings.rendererLibrary)) { + renderers = + scene.createStandardRenderers(settings.rendererLibrary, device); + anari::release(device, device); + } else { + tsd::core::logWarning( + "[SciVisStudio] failed to load ANARI device '%s' for shot " + "renderer selection", + settings.rendererLibrary.c_str()); + } + } + } + + tsd::scene::RendererAppRef currentRenderer; + if (ctx && settings.rendererObjectIndex != TSD_INVALID_INDEX) { + auto renderer = ctx->tsd.scene.getObject( + settings.rendererObjectIndex); + if (renderer && renderer->rendererDeviceName() == settings.rendererLibrary) + currentRenderer = renderer; + } + + if (!currentRenderer && !renderers.empty()) { + currentRenderer = renderers.front(); + if (settings.rendererObjectIndex != currentRenderer->index() + || settings.rendererSubtype != currentRenderer->subtype().str()) { + settings.rendererObjectIndex = currentRenderer->index(); + settings.rendererSubtype = currentRenderer->subtype().str(); + project.markDirty(); + } + } + + const auto preview = currentRenderer ? rendererLabel(*currentRenderer) + : std::string{NO_RENDERERS_LABEL}; + ImGui::BeginDisabled(renderers.empty()); + if (ImGui::BeginCombo("Renderer", preview.c_str())) { + for (const auto &renderer : renderers) { + if (!renderer) + continue; + const bool selected = renderer->index() == settings.rendererObjectIndex; + const auto label = rendererLabel(*renderer); + if (ImGui::Selectable(label.c_str(), selected)) { + if (settings.rendererObjectIndex != renderer->index() + || settings.rendererSubtype != renderer->subtype().str()) { + settings.rendererObjectIndex = renderer->index(); + settings.rendererSubtype = renderer->subtype().str(); + project.markDirty(); + } + } + if (selected) + ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + ImGui::EndDisabled(); +} + +void ShotEditor::buildUI_lightRigSelector(Shot &shot) +{ + auto &project = m_projectContext->project(); + std::string preview = "None"; + if (!shot.lightRigId.empty()) { + if (auto *rig = project::findLightRig(project, shot.lightRigId)) + preview = rig->name; + else + preview = ""; + } + + if (ImGui::BeginCombo("Light Rig", preview.c_str())) { + const bool noneSelected = shot.lightRigId.empty(); + if (ImGui::Selectable("None", noneSelected)) { + if (!shot.lightRigId.empty()) { + shot.lightRigId.clear(); + project.markDirty(); + m_projectContext->applyActiveShot(); + } + } + if (noneSelected) + ImGui::SetItemDefaultFocus(); + + for (const auto &rig : project.lightRigs) { + const bool selected = shot.lightRigId == rig.id; + if (ImGui::Selectable(rig.name.c_str(), selected)) { + if (shot.lightRigId != rig.id) { + shot.lightRigId = rig.id; + project.markDirty(); + m_projectContext->applyActiveShot(); + } + } + if (selected) + ImGui::SetItemDefaultFocus(); + } + + if (!shot.lightRigId.empty() && !project::findLightRig(project, shot.lightRigId)) { + const auto missing = ""; + ImGui::TextDisabled("%s", missing.c_str()); + } + ImGui::EndCombo(); + } +} + +void ShotEditor::buildUI() +{ + if (!m_projectContext) + return; + + auto &project = m_projectContext->project(); + auto *shot = project::activeShot(project); + if (!shot) { + ImGui::TextDisabled("No active shot"); + return; + } + auto *ctx = m_projectContext->appContext(); + + if (inputText("Name", shot->name)) + project.markDirty(); + + int currentFrame = shot->currentFrame; + int frameCount = shot->frameCount; + float fps = shot->fps; + if (ImGui::InputInt("Current frame", ¤tFrame)) { + shot->currentFrame = std::clamp(currentFrame, 0, shot->frameCount - 1); + project.markDirty(); + if (ctx) + ctx->tsd.animationMgr.setAnimationFrame(shot->currentFrame); + else + m_projectContext->applyActiveShot(); + } + bool playbackSettingsChanged = false; + playbackSettingsChanged |= ImGui::InputInt("Frame count", &frameCount); + playbackSettingsChanged |= ImGui::InputFloat("FPS", &fps); + if (playbackSettingsChanged) { + shot->frameCount = std::max(1, frameCount); + shot->currentFrame = + std::clamp(shot->currentFrame, 0, shot->frameCount - 1); + shot->fps = std::max(1.f, fps); + project.markDirty(); + m_projectContext->syncAnimationManagerToActiveShot(); + m_projectContext->applyActiveShot(); + } + + const bool playing = ctx ? ctx->tsd.animationMgr.isPlaying() : shot->playing; + if (ImGui::Button(playing ? "Stop" : "Play")) { + if (ctx) { + if (ctx->tsd.animationMgr.isPlaying()) + ctx->tsd.animationMgr.stop(); + else + ctx->tsd.animationMgr.play(); + shot->playing = ctx->tsd.animationMgr.isPlaying(); + } else + shot->playing = !shot->playing; + } + ImGui::SameLine(); + if (ImGui::Checkbox("Loop", &shot->loop)) { + project.markDirty(); + m_projectContext->syncAnimationManagerToActiveShot(); + } + + ImGui::SeparatorText("Render"); + int width = static_cast(shot->renderSettings.width); + int height = static_cast(shot->renderSettings.height); + int samples = static_cast(shot->renderSettings.samples); + if (ImGui::InputInt("Width", &width)) { + shot->renderSettings.width = static_cast(std::max(1, width)); + project.markDirty(); + } + if (ImGui::InputInt("Height", &height)) { + shot->renderSettings.height = static_cast(std::max(1, height)); + project.markDirty(); + } + if (ImGui::InputInt("Samples", &samples)) { + shot->renderSettings.samples = static_cast(std::max(1, samples)); + project.markDirty(); + } + buildUI_deviceSelector(*shot); + buildUI_rendererSelector(*shot); + if (inputText("Output prefix", shot->renderSettings.outputFilePrefix)) + project.markDirty(); + buildUI_lightRigSelector(*shot); + + ImGui::Text("Output: renders/%s/", shot->id.c_str()); + if (ImGui::Button("Render Active Shot") && m_onRender) + m_onRender(); + + ImGui::SeparatorText("Datasets"); + for (const auto &dataset : project.datasets) { + bool enabled = true; + if (auto *binding = shot::findDatasetBinding(*shot, dataset.id)) + enabled = binding->enabled; + if (ImGui::Checkbox(dataset.name.c_str(), &enabled)) { + shot::setDatasetBinding(*shot, dataset.id, enabled); + project.markDirty(); + m_projectContext->applyActiveShot(); + } + } +} + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/windows/ShotEditor.h b/tsd/apps/interactive/scivisStudio/windows/ShotEditor.h new file mode 100644 index 000000000..3d46df4eb --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/windows/ShotEditor.h @@ -0,0 +1,34 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "ProjectContext.h" +#include "tsd/ui/imgui/windows/Window.h" + +#include +#include + +namespace tsd::scivis_studio { + +struct ShotEditor : public tsd::ui::imgui::Window +{ + ShotEditor(tsd::ui::imgui::Application *app, + ProjectContext *projectContext, + std::function onRender); + ~ShotEditor() override; + + void buildUI() override; + + private: + bool inputText(const char *label, std::string &value, size_t capacity = 512); + void buildUI_deviceSelector(Shot &shot); + void buildUI_rendererSelector(Shot &shot); + void buildUI_lightRigSelector(Shot &shot); + + ProjectContext *m_projectContext{nullptr}; + std::function m_onRender; + std::string m_rendererLoadAttemptedLibrary; +}; + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/viewer/README.md b/tsd/apps/interactive/viewer/README.md index fc36305d7..5acf0d355 100644 --- a/tsd/apps/interactive/viewer/README.md +++ b/tsd/apps/interactive/viewer/README.md @@ -118,6 +118,24 @@ Start secondary viewport on another ANARI library: ./tsdViewer -sv visgl -gltf scene.gltf ``` +## User Color Maps + +The transfer-function editor loads optional RGB color-map presets once at +startup from the standard TSD config directory: + +- Linux/macOS: `~/.config/tsd/colormaps/` +- Windows: `%APPDATA%/tsd/colormaps/` + +Only `.1dt` files are loaded. The filename stem becomes the color-map name in +the editor, so `Magma Soft.1dt` appears as `Magma Soft`. Files are loaded in +alphabetical order after the built-in palettes; if a user file has the same +name as an earlier palette, it replaces that palette. + +Each non-comment line should contain whitespace-separated `r g b a` values. +The auto-loaded presets use only RGB values; alpha is ignored so selecting one +does not change the current opacity curve. Manual transfer-function load/save +continues to use `.1dt` as full RGBA transfer-function state. + ## Environment `TSD_ANARI_LIBRARIES` controls the ANARI library list shown in the UI (comma- diff --git a/tsd/apps/interactive/viewer/tsdViewer.cpp b/tsd/apps/interactive/viewer/tsdViewer.cpp index 41e06b2da..c736d8ac2 100644 --- a/tsd/apps/interactive/viewer/tsdViewer.cpp +++ b/tsd/apps/interactive/viewer/tsdViewer.cpp @@ -31,6 +31,60 @@ class Application : public TSDApplication Application(int argc, const char *argv[]) : TSDApplication(argc, argv) {} ~Application() override = default; + tsd::core::DataTreeMetadata applicationStateMetadata() const override + { + return {tsd::core::DATA_TREE_METADATA_ENVELOPE_VERSION, + "application-state", + "tsd.viewer.state", + 1}; + } + + bool validateApplicationStateMetadata( + const tsd::core::DataTreeMetadataReadResult &metadata, + const tsd::core::DataNode &root, + const char *filename) const override + { + if (metadata.status == tsd::core::DataTreeMetadataReadStatus::Missing) { + if (auto *projectKindNode = root.child("projectKind")) { + const auto projectKind = + projectKindNode->getValueOr(""); + tsd::core::logError( + "[tsdViewer] Refusing to load '%s': projectKind='%s' is not a " + "tsdViewer state file", + filename, + projectKind.c_str()); + return false; + } + + tsd::core::logWarning( + "[tsdViewer] State file '%s' has no __tsd_metadata; loading as " + "legacy viewer state", + filename); + return true; + } + + if (metadata.status == tsd::core::DataTreeMetadataReadStatus::Malformed) { + tsd::core::logError("[tsdViewer] Refusing to load state file '%s': %s", + filename, + metadata.message.c_str()); + return false; + } + + const auto &stateMetadata = *metadata.metadata; + if (stateMetadata.fileType != "application-state" + || stateMetadata.schema != "tsd.viewer.state") { + tsd::core::logError( + "[tsdViewer] Refusing to load '%s': expected tsd.viewer.state, got " + "fileType='%s' schema='%s'", + filename, + stateMetadata.fileType.c_str(), + stateMetadata.schema.c_str()); + return false; + } + + return true; + } + tsd::ui::imgui::WindowArray setupWindows() override { auto windows = TSDApplication::setupWindows(); diff --git a/tsd/config/README.md b/tsd/config/README.md new file mode 100644 index 000000000..0dd425e21 --- /dev/null +++ b/tsd/config/README.md @@ -0,0 +1,18 @@ +# TSD User Color Maps + +This directory contains example user color-map files for TSD applications. + +To make TSD pick up a color map, copy a `.1dt` file into your user config +color-map directory: + +- Linux/macOS: `~/.config/tsd/colormaps/` +- Windows: `%APPDATA%/tsd/colormaps/` + +TSD loads these files once at application startup. The filename stem becomes +the transfer-function editor dropdown name, so `Sunset Test.1dt` appears as +`Sunset Test`. + +Each non-comment line should contain whitespace-separated `r g b a` values. +For auto-loaded color-map presets, TSD uses only RGB values; alpha is ignored. +Manual transfer-function load/save still treats `.1dt` as full RGBA transfer +function state. diff --git a/tsd/config/Sunset Test.1dt b/tsd/config/Sunset Test.1dt new file mode 100644 index 000000000..f5f6dc35c --- /dev/null +++ b/tsd/config/Sunset Test.1dt @@ -0,0 +1,8 @@ +# Sunset Test - sample RGB palette for TSD +# Rows are r g b a; alpha is ignored by auto-loaded RGB palettes. +0.050 0.070 0.180 1.0 +0.190 0.120 0.330 0.8 +0.470 0.160 0.390 0.6 +0.800 0.260 0.300 0.4 +0.980 0.520 0.220 0.2 +1.000 0.840 0.430 0.0 diff --git a/tsd/src/tsd/app/ANARIDeviceManager.cpp b/tsd/src/tsd/app/ANARIDeviceManager.cpp index 9c32b555c..1041b8697 100644 --- a/tsd/src/tsd/app/ANARIDeviceManager.cpp +++ b/tsd/src/tsd/app/ANARIDeviceManager.cpp @@ -92,10 +92,16 @@ void ANARIDeviceManager::setLibraryList(const std::vector &libs) m_libraryList = libs; } +bool ANARIDeviceManager::isLoadableLibrary( + const std::string &libraryName) const +{ + return !libraryName.empty() && libraryName != "{none}"; +} + anari::Device ANARIDeviceManager::loadDevice(const std::string &libraryName, const std::vector &initialDeviceParams) { - if (libraryName.empty() || libraryName == "{none}") + if (!isLoadableLibrary(libraryName)) return nullptr; anari::Device dev = m_loadedDevices[libraryName]; diff --git a/tsd/src/tsd/app/ANARIDeviceManager.h b/tsd/src/tsd/app/ANARIDeviceManager.h index 00fb3e754..f184869cc 100644 --- a/tsd/src/tsd/app/ANARIDeviceManager.h +++ b/tsd/src/tsd/app/ANARIDeviceManager.h @@ -38,6 +38,7 @@ struct ANARIDeviceManager const std::vector &libraryList() const; void setLibraryList(const std::vector &libs); + bool isLoadableLibrary(const std::string &libName) const; anari::Device loadDevice(const std::string &libName, const std::vector &initialDeviceParams = {}); diff --git a/tsd/src/tsd/app/Context.cpp b/tsd/src/tsd/app/Context.cpp index 219bd1523..b78d1a203 100644 --- a/tsd/src/tsd/app/Context.cpp +++ b/tsd/src/tsd/app/Context.cpp @@ -382,7 +382,9 @@ void Context::addCurrentViewToCameraPoses(const char *_name) pose.name = name; pose.lookat = view.manipulator.at(); pose.azeldist = azeldist; + pose.fixedDist = view.manipulator.fixedDistance(); pose.upAxis = static_cast(view.manipulator.axis()); + pose.mode = static_cast(view.manipulator.mode()); view.poses.push_back(std::move(pose)); } @@ -410,7 +412,9 @@ void Context::addTurntableCameraPoses(const tsd::math::float3 &azs, pose.name = baseName + "_" + std::to_string(i) + "_" + std::to_string(j); pose.lookat = center; pose.azeldist = {az, el, dist}; + pose.fixedDist = view.manipulator.fixedDistance(); pose.upAxis = static_cast(view.manipulator.axis()); + pose.mode = static_cast(view.manipulator.mode()); view.poses.push_back(std::move(pose)); } } @@ -424,7 +428,9 @@ void Context::updateExistingCameraPoseFromView(CameraPose &p) p.lookat = view.manipulator.at(); p.azeldist = azeldist; + p.fixedDist = view.manipulator.fixedDistance(); p.upAxis = static_cast(view.manipulator.axis()); + p.mode = static_cast(view.manipulator.mode()); } bool Context::updateCameraPathAnimation() @@ -530,9 +536,8 @@ bool Context::updateCameraPathAnimation() void Context::setCameraPose(const CameraPose &pose) { - view.manipulator.setConfig( - pose.lookat, pose.azeldist.z, {pose.azeldist.x, pose.azeldist.y}); - view.manipulator.setAxis(static_cast(pose.upAxis)); + view.manipulator.setConfig(pose); + view.manipulator.setFixedDistance(pose.fixedDist); } void Context::removeAllPoses() diff --git a/tsd/src/tsd/core/DataTreeMetadata.hpp b/tsd/src/tsd/core/DataTreeMetadata.hpp new file mode 100644 index 000000000..76e4cc8e3 --- /dev/null +++ b/tsd/src/tsd/core/DataTreeMetadata.hpp @@ -0,0 +1,127 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/core/DataTree.hpp" +// std +#include +#include + +namespace tsd::core { + +inline constexpr const char *DATA_TREE_METADATA_NODE = "__tsd_metadata"; +inline constexpr int DATA_TREE_METADATA_ENVELOPE_VERSION = 1; + +struct DataTreeMetadata +{ + int envelopeVersion{DATA_TREE_METADATA_ENVELOPE_VERSION}; + std::string fileType; + std::string schema; + int schemaVersion{1}; +}; + +enum class DataTreeMetadataReadStatus +{ + Found, + Missing, + Malformed +}; + +struct DataTreeMetadataReadResult +{ + DataTreeMetadataReadStatus status{DataTreeMetadataReadStatus::Missing}; + std::optional metadata; + std::string message; + + bool found() const; + bool malformed() const; +}; + +inline bool DataTreeMetadataReadResult::found() const +{ + return status == DataTreeMetadataReadStatus::Found; +} + +inline bool DataTreeMetadataReadResult::malformed() const +{ + return status == DataTreeMetadataReadStatus::Malformed; +} + +inline void writeDataTreeMetadata( + DataNode &root, const DataTreeMetadata &metadata) +{ + auto &metadataNode = root[DATA_TREE_METADATA_NODE]; + metadataNode["envelopeVersion"] = metadata.envelopeVersion; + metadataNode["fileType"] = metadata.fileType; + metadataNode["schema"] = metadata.schema; + metadataNode["schemaVersion"] = metadata.schemaVersion; +} + +inline DataTreeMetadataReadResult readDataTreeMetadata(const DataNode &root) +{ + auto *metadataNode = root.child(DATA_TREE_METADATA_NODE); + if (!metadataNode) + return {}; + + auto requiredNode = [&](const char *name, anari::DataType type) + -> const DataNode * { + auto *node = metadataNode->child(name); + if (!node) + return nullptr; + + const auto actualType = node->getValue().type(); + if (actualType != type) + return nullptr; + + return node; + }; + + auto malformed = [](std::string message) { + DataTreeMetadataReadResult result; + result.status = DataTreeMetadataReadStatus::Malformed; + result.message = std::move(message); + return result; + }; + + auto describeMissingOrWrongType = + [&](const char *name, anari::DataType type) -> std::string { + std::string message = std::string(DATA_TREE_METADATA_NODE) + "/" + name + + " must be " + anari::toString(type); + if (auto *node = metadataNode->child(name)) { + message += ", got "; + message += anari::toString(node->getValue().type()); + } else + message += ", but is missing"; + return message; + }; + + auto *envelopeVersion = requiredNode("envelopeVersion", ANARI_INT32); + if (!envelopeVersion) + return malformed(describeMissingOrWrongType("envelopeVersion", ANARI_INT32)); + + auto *fileType = requiredNode("fileType", ANARI_STRING); + if (!fileType) + return malformed(describeMissingOrWrongType("fileType", ANARI_STRING)); + + auto *schema = requiredNode("schema", ANARI_STRING); + if (!schema) + return malformed(describeMissingOrWrongType("schema", ANARI_STRING)); + + auto *schemaVersion = requiredNode("schemaVersion", ANARI_INT32); + if (!schemaVersion) + return malformed(describeMissingOrWrongType("schemaVersion", ANARI_INT32)); + + DataTreeMetadata metadata; + metadata.envelopeVersion = envelopeVersion->getValueAs(); + metadata.fileType = fileType->getValueAs(); + metadata.schema = schema->getValueAs(); + metadata.schemaVersion = schemaVersion->getValueAs(); + + DataTreeMetadataReadResult result; + result.status = DataTreeMetadataReadStatus::Found; + result.metadata = std::move(metadata); + return result; +} + +} // namespace tsd::core diff --git a/tsd/src/tsd/core/TSDMath.hpp b/tsd/src/tsd/core/TSDMath.hpp index fa5bf9b35..dc4a27798 100644 --- a/tsd/src/tsd/core/TSDMath.hpp +++ b/tsd/src/tsd/core/TSDMath.hpp @@ -9,6 +9,8 @@ // helium #include // std +#include +#include #include namespace tsd { @@ -54,6 +56,18 @@ static constexpr tsd::core::math::float3 degrees(tsd::core::math::float3 v) return tsd::core::math::float3(degrees(v.x), degrees(v.y), degrees(v.z)); }; +inline float normalizeDegrees(float v) +{ + v = std::fmod(v, 360.f); + return v < 0.f ? v + 360.f : v; +} + +inline tsd::core::math::float3 normalizeDegrees(tsd::core::math::float3 v) +{ + return tsd::core::math::float3( + normalizeDegrees(v.x), normalizeDegrees(v.y), normalizeDegrees(v.z)); +} + static constexpr tsd::core::math::float3 azelToDir(tsd::core::math::float2 azel) { const float az = radians(azel.x); @@ -117,13 +131,24 @@ inline void decomposeMatrix(const tsd::core::math::mat4 &m, inline tsd::core::math::float3 matrixToAzElRoll(const tsd::core::math::mat4 &r) { - const float r00 = r[0][0], r01 = r[0][1], r02 = r[0][2]; - const float r10 = r[1][0], r11 = r[1][1], r12 = r[1][2]; - const float r20 = r[2][0], r21 = r[2][1], r22 = r[2][2]; - - const float elevation = std::asin(-r21); - const float azimuth = std::atan2(r20, r22); - const float roll = std::atan2(r01, r11); + const float m00 = r[0][0]; + const float m01 = r[1][0]; + const float m02 = r[2][0]; + const float m10 = r[0][1]; + const float m11 = r[1][1]; + const float m12 = r[2][1]; + const float m22 = r[2][2]; + + const float elevation = std::asin(std::clamp(-m12, -1.f, 1.f)); + float azimuth = 0.f; + float roll = 0.f; + + if (std::abs(std::cos(elevation)) > 1e-5f) { + azimuth = std::atan2(m02, m22); + roll = std::atan2(m10, m11); + } else { + roll = std::atan2(-m01, m00); + } return {azimuth, elevation, roll}; } diff --git a/tsd/src/tsd/io/CMakeLists.txt b/tsd/src/tsd/io/CMakeLists.txt index dc123ac6e..be63741b9 100644 --- a/tsd/src/tsd/io/CMakeLists.txt +++ b/tsd/src/tsd/io/CMakeLists.txt @@ -63,6 +63,7 @@ PRIVATE serialization/NanoVdbSidecar.cpp serialization/export_SceneToUSD.cpp serialization/serialization_datatree.cpp + serialization/serialization_object.cpp ) project_include_directories( diff --git a/tsd/src/tsd/io/importers.hpp b/tsd/src/tsd/io/importers.hpp index 6be5d0823..2cade9f47 100644 --- a/tsd/src/tsd/io/importers.hpp +++ b/tsd/src/tsd/io/importers.hpp @@ -7,9 +7,11 @@ #include "tsd/core/FlatMap.hpp" #include "tsd/scene/Scene.hpp" // std +#include #include #include #include +#include namespace tsd::animation { struct AnimationManager; @@ -173,6 +175,20 @@ enum class ImporterType using ImportFile = std::pair; using ImportAnimationFiles = std::pair>; +struct UserColorMap +{ + std::string name; + std::filesystem::path path; + std::vector colorPoints; +}; + +std::filesystem::path userColorMapDirectory(); +std::vector loadUserColorMaps(); +std::vector loadUserColorMaps( + const std::filesystem::path &directory); +tsd::core::TransferFunction importTransferFunction( + const std::string &filepath); + void import_file(Scene &scene, tsd::animation::AnimationManager &animMgr, const ImportFile &file, diff --git a/tsd/src/tsd/io/importers/detail/importer_common.cpp b/tsd/src/tsd/io/importers/detail/importer_common.cpp index 191fd909b..98a351ba3 100644 --- a/tsd/src/tsd/io/importers/detail/importer_common.cpp +++ b/tsd/src/tsd/io/importers/detail/importer_common.cpp @@ -7,6 +7,7 @@ #include "tsd/core/Logging.hpp" #include "tsd/core/Token.hpp" // tsd_io +#include "tsd/io/importers.hpp" #include "tsd/io/importers/detail/dds.h" // mikktspace #include "mikktspace.h" @@ -23,8 +24,11 @@ #include #include #include +#include +#include #include #include +#include #include using U64Vec2 = tsd::math::vec; @@ -699,6 +703,13 @@ static core::TransferFunction import1dtTransferFunction( filepath.c_str()); return {}; } + if (colors.size() < 2) { + logError( + "[import1dtTransferFunction] Expected at least two RGBA entries in " + "file: %s", + filepath.c_str()); + return {}; + } const float normalizer = 1.0f / static_cast(colors.size() - 1); for (auto &c : colors) @@ -915,6 +926,86 @@ core::TransferFunction importTransferFunction(const std::string &filepath) return {}; } +std::filesystem::path userColorMapDirectory() +{ +#ifdef _WIN32 + if (const char *appData = std::getenv("APPDATA"); appData != nullptr) + return std::filesystem::path(appData) / "tsd" / "colormaps"; +#else + if (const char *home = std::getenv("HOME"); home != nullptr) + return std::filesystem::path(home) / ".config" / "tsd" / "colormaps"; +#endif + + return std::filesystem::path("colormaps"); +} + +std::vector loadUserColorMaps() +{ + return loadUserColorMaps(userColorMapDirectory()); +} + +std::vector loadUserColorMaps( + const std::filesystem::path &directory) +{ + namespace fs = std::filesystem; + + std::error_code ec; + if (!fs::exists(directory, ec) || !fs::is_directory(directory, ec)) + return {}; + + std::vector files; + for (fs::directory_iterator it(directory, ec), end; !ec && it != end; + it.increment(ec)) { + const auto &entry = *it; + if (!entry.is_regular_file(ec)) + continue; + + auto ext = entry.path().extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); + if (ext == ".1dt") + files.push_back(entry.path()); + } + + if (ec) { + logWarning("[loadUserColorMaps] Failed to scan directory '%s': %s", + directory.string().c_str(), + ec.message().c_str()); + } + + std::sort(files.begin(), files.end(), [](const fs::path &a, + const fs::path &b) { + return a.stem().string() < b.stem().string(); + }); + + std::vector colorMaps; + for (const auto &file : files) { + auto tfn = importTransferFunction(file.string()); + if (tfn.colorPoints.size() < 2) { + logWarning("[loadUserColorMaps] Skipping color map '%s'", + file.string().c_str()); + continue; + } + + UserColorMap colorMap; + colorMap.name = file.stem().string(); + colorMap.path = file; + colorMap.colorPoints = std::move(tfn.colorPoints); + + auto existing = std::find_if(colorMaps.begin(), colorMaps.end(), + [&](const UserColorMap &other) { return other.name == colorMap.name; }); + if (existing != colorMaps.end()) { + logStatus("[loadUserColorMaps] Replaced color map '%s' from '%s'", + colorMap.name.c_str(), + colorMap.path.string().c_str()); + *existing = std::move(colorMap); + } else { + colorMaps.push_back(std::move(colorMap)); + } + } + + return colorMaps; +} + #if TSD_USE_VTK anari::DataType vtkTypeToANARIType( int vtkType, int numComps, const char *errorIdentifier) diff --git a/tsd/src/tsd/io/importers/import_file.cpp b/tsd/src/tsd/io/importers/import_file.cpp index 7bb18b749..91806eb8c 100644 --- a/tsd/src/tsd/io/importers/import_file.cpp +++ b/tsd/src/tsd/io/importers/import_file.cpp @@ -9,12 +9,33 @@ namespace tsd::io { +namespace { + +void ensureDefaultTransferFunction(tsd::core::TransferFunction &tf) +{ + if (!tf.colorPoints.empty() || !tf.opacityPoints.empty()) + return; + + for (const auto &c : core::colormap::viridis) { + tf.colorPoints.push_back({float(tf.colorPoints.size()) + / float(core::colormap::viridis.size() - 1), + c.x, + c.y, + c.z}); + } + tf.opacityPoints = {{0.0f, 0.0f}, {1.0f, 1.0f}}; + tf.range = {}; +} + +} // namespace + void import_file(Scene &scene, tsd::animation::AnimationManager &animMgr, const ImportFile &f, tsd::scene::LayerNodeRef root) { tsd::core::TransferFunction tf; + ensureDefaultTransferFunction(tf); import_file(scene, animMgr, f, tf, root); } @@ -95,8 +116,7 @@ void import_file(Scene &scene, if (files.size() > 2 && !files[2].empty()) prop = files[2]; tsd::io::import_VTU(scene, animMgr, file.c_str(), root, std::move(prop)); - } - else if (f.first == ImporterType::XYZDP) + } else if (f.first == ImporterType::XYZDP) tsd::io::import_XYZDP(scene, animMgr, file.c_str(), root); else if (f.first == ImporterType::VOLUME) tsd::io::import_volume(scene, file.c_str(), tf, root); @@ -124,8 +144,7 @@ void import_files(Scene &s, tsd::core::TransferFunction tf, tsd::scene::LayerNodeRef root) { - if (tf.colorPoints.empty() && tf.opacityPoints.empty()) - tf = tsd::core::makeDefaultTransferFunction(); + ensureDefaultTransferFunction(tf); const size_t rank = s.mpiRank(); const size_t numRanks = s.mpiNumRanks(); @@ -142,8 +161,9 @@ void import_animations(Scene &scene, const std::vector &files, tsd::scene::LayerNodeRef root) { - import_animations( - scene, animMgr, files, tsd::core::makeDefaultTransferFunction(), root); + tsd::core::TransferFunction tf; + ensureDefaultTransferFunction(tf); + import_animations(scene, animMgr, files, tf, root); } void import_animations(Scene &scene, diff --git a/tsd/src/tsd/io/importers/import_volume.cpp b/tsd/src/tsd/io/importers/import_volume.cpp index d1c40a0f8..3dda75d7e 100644 --- a/tsd/src/tsd/io/importers/import_volume.cpp +++ b/tsd/src/tsd/io/importers/import_volume.cpp @@ -20,19 +20,20 @@ using namespace tsd::core; // Helper functions /////////////////////////////////////////////////////////// -void applyTransferFunction(Scene &scene, - VolumeRef volume, - const tsd::core::TransferFunction &transferFunction) +void applyTransferFunction( + Scene &scene, VolumeRef volume, const tsd::core::TransferFunction &tf) { - if (!volume) + if (!volume) { + logError( + "[applyTransferFunction] cannot apply transfer function to null volume"); return; + } - // Fallback tf in case none is provided. So interpolate*() work. - const auto fallback = (transferFunction.colorPoints.empty() - || transferFunction.opacityPoints.empty()) - ? std::optional(makeDefaultTransferFunction()) - : std::nullopt; - const auto &tf = fallback ? *fallback : transferFunction; + if (tf.colorPoints.empty() || tf.opacityPoints.empty()) { + logError( + "[applyTransferFunction] transfer function must have color and opacity control points"); + return; + } // Build RGBA colors with evenly-spaced positions std::vector colormap; @@ -43,8 +44,8 @@ void applyTransferFunction(Scene &scene, float x = (i / float(numRGBPoints - 1)); auto color = detail::interpolateColor(tf.colorPoints, x); - auto opacty = detail::interpolateOpacity(tf.opacityPoints, x); - colormap.push_back({color.x, color.y, color.z, opacty}); + auto opacity = detail::interpolateOpacity(tf.opacityPoints, x); + colormap.push_back({color.x, color.y, color.z, opacity}); } auto colorArray = scene.createArray(ANARI_FLOAT32_VEC4, colormap.size()); diff --git a/tsd/src/tsd/io/serialization.hpp b/tsd/src/tsd/io/serialization.hpp index 0fa9d4705..e3cdc2f54 100644 --- a/tsd/src/tsd/io/serialization.hpp +++ b/tsd/src/tsd/io/serialization.hpp @@ -9,6 +9,9 @@ #include "tsd/rendering/view/Manipulator.hpp" // tsd_scene #include "tsd/scene/Scene.hpp" +// std +#include +#include namespace tsd::animation { struct Animation; @@ -30,6 +33,46 @@ enum class VDBPrecision Half // IEEE 16-bit half float }; +namespace schema { + +inline constexpr std::string_view SCENE_FULL = "tsd.scene.full"; +inline constexpr std::string_view SCENE_CAMERAS_AND_RENDERERS = + "tsd.scene.cameras-and-renderers"; +inline constexpr std::string_view OBJECT_SURFACE = "tsd.object.surface"; +inline constexpr std::string_view OBJECT_VOLUME = "tsd.object.volume"; + +} // namespace schema + +enum class PayloadValidationStatus +{ + Valid, + MissingMetadataAccepted, + UnknownSchema, + IncompatibleSchema, + UnsupportedEnvelopeVersion, + UnsupportedSchemaVersion, + MalformedMetadata, + MissingRequiredNode +}; + +struct PayloadValidationResult +{ + PayloadValidationStatus status{PayloadValidationStatus::Valid}; + std::string fileType; + std::string schema; + int envelopeVersion{0}; + int schemaVersion{0}; + std::string message; + + bool accepted() const; +}; + +inline bool PayloadValidationResult::accepted() const +{ + return status == PayloadValidationStatus::Valid + || status == PayloadValidationStatus::MissingMetadataAccepted; +} + // clang-format off // Parameters // @@ -68,6 +111,21 @@ void save_Scene(Scene &scene, const char *filename); void save_Scene(Scene &scene, core::DataNode &root, bool forceProxyArrays, tsd::animation::AnimationManager *animMgr = nullptr); void load_Scene(Scene &scene, const char *filename, tsd::animation::AnimationManager *animMgr = nullptr); void load_Scene(Scene &scene, core::DataNode &root, tsd::animation::AnimationManager *animMgr = nullptr); +PayloadValidationResult validate_ScenePayload(core::DataNode &root); +bool tryLoad_Scene(Scene &scene, core::DataNode &root, PayloadValidationResult *result = nullptr, tsd::animation::AnimationManager *animMgr = nullptr); +void save_SceneCamerasAndRenderers(Scene &scene, const char *filename); +void save_SceneCamerasAndRenderers(Scene &scene, core::DataNode &root); +void load_SceneCamerasAndRenderers(Scene &scene, const char *filename); +void load_SceneCamerasAndRenderers(Scene &scene, core::DataNode &root); +PayloadValidationResult validate_SceneCamerasAndRenderersPayload(core::DataNode &root); +bool tryLoad_SceneCamerasAndRenderers(Scene &scene, core::DataNode &root, PayloadValidationResult *result = nullptr); +bool export_Object(const char *filename, const Object &obj); +Object *import_Object(Scene &scene, const char *filename); +SurfaceRef import_Surface(Scene &scene, const char *filename); +VolumeRef import_Volume(Scene &scene, const char *filename); +PayloadValidationResult validate_ObjectPayload(core::DataNode &root); +PayloadValidationResult validate_SurfacePayload(core::DataNode &root); +PayloadValidationResult validate_VolumePayload(core::DataNode &root); void export_SceneToUSD( Scene &scene, const char *filename, int framesPerSecond = 30, tsd::animation::AnimationManager *animMgr = nullptr); diff --git a/tsd/src/tsd/io/serialization/serialization_datatree.cpp b/tsd/src/tsd/io/serialization/serialization_datatree.cpp index 361a31db6..af8c4539a 100644 --- a/tsd/src/tsd/io/serialization/serialization_datatree.cpp +++ b/tsd/src/tsd/io/serialization/serialization_datatree.cpp @@ -7,6 +7,7 @@ #include "tsd/animation/Animation.hpp" #include "tsd/animation/AnimationManager.hpp" +#include "tsd/core/DataTreeMetadata.hpp" #include "tsd/core/DataTree.hpp" #include "tsd/core/Logging.hpp" #include "tsd/io/animation/EnSightFileBinding.hpp" @@ -14,9 +15,11 @@ #include "tsd/io/importers.hpp" #include "tsd/io/serialization.hpp" // std +#include #include #include #include +#include #if TSD_USE_CUDA // cuda #include @@ -24,6 +27,132 @@ namespace tsd::io { +static core::DataNode &resolveScenePayloadRoot(core::DataNode &root) +{ + if (auto *context = root.child("context")) + return *context; + return root; +} + +static std::string validationStatusToString(PayloadValidationStatus status) +{ + switch (status) { + case PayloadValidationStatus::Valid: + return "valid"; + case PayloadValidationStatus::MissingMetadataAccepted: + return "missing metadata accepted"; + case PayloadValidationStatus::UnknownSchema: + return "unknown schema"; + case PayloadValidationStatus::IncompatibleSchema: + return "incompatible schema"; + case PayloadValidationStatus::UnsupportedEnvelopeVersion: + return "unsupported envelope version"; + case PayloadValidationStatus::UnsupportedSchemaVersion: + return "unsupported schema version"; + case PayloadValidationStatus::MalformedMetadata: + return "malformed metadata"; + case PayloadValidationStatus::MissingRequiredNode: + return "missing required node"; + } + + return "unknown validation status"; +} + +static void logValidationFailure( + const char *prefix, const PayloadValidationResult &result) +{ + logError("[%s] payload validation failed: %s%s%s", + prefix, + validationStatusToString(result.status).c_str(), + result.message.empty() ? "" : ": ", + result.message.c_str()); +} + +static PayloadValidationResult validateScenePayloadImpl(core::DataNode &root, + const std::vector &acceptedSchemas, + const std::vector &knownSchemas) +{ + auto &payloadRoot = resolveScenePayloadRoot(root); + auto metadataResult = core::readDataTreeMetadata(payloadRoot); + + PayloadValidationResult result; + if (metadataResult.malformed()) { + result.status = PayloadValidationStatus::MalformedMetadata; + result.message = metadataResult.message; + return result; + } + + if (metadataResult.found()) { + const auto &metadata = *metadataResult.metadata; + result.fileType = metadata.fileType; + result.schema = metadata.schema; + result.envelopeVersion = metadata.envelopeVersion; + result.schemaVersion = metadata.schemaVersion; + + if (metadata.envelopeVersion + != core::DATA_TREE_METADATA_ENVELOPE_VERSION) { + result.status = PayloadValidationStatus::UnsupportedEnvelopeVersion; + result.message = "expected envelopeVersion 1, got " + + std::to_string(metadata.envelopeVersion); + return result; + } + + const auto schemaMatches = [&](std::string_view schema) { + return metadata.schema == schema; + }; + + if (std::none_of( + acceptedSchemas.begin(), acceptedSchemas.end(), schemaMatches)) { + result.status = std::any_of( + knownSchemas.begin(), knownSchemas.end(), schemaMatches) + ? PayloadValidationStatus::IncompatibleSchema + : PayloadValidationStatus::UnknownSchema; + result.message = "schema '" + metadata.schema + + "' is not accepted by this loader"; + return result; + } + + if (metadata.schemaVersion != 1) { + result.status = PayloadValidationStatus::UnsupportedSchemaVersion; + result.message = "schema '" + metadata.schema + + "' supports version 1..1, got " + + std::to_string(metadata.schemaVersion); + return result; + } + } else { + result.status = PayloadValidationStatus::MissingMetadataAccepted; + result.message = "payload has no __tsd_metadata node; treating as legacy"; + } + + if (!payloadRoot.child("objectDB")) { + result.status = PayloadValidationStatus::MissingRequiredNode; + result.message = "payload requires root/objectDB"; + } + + return result; +} + +template +static void objectPoolToNode(core::DataNode &objPoolRoot, + const OBJECT_POOL_T &objPool, + const char *poolName, + bool forceProxyArrays) +{ + if (objPool.empty()) + return; + + tsd::core::logStatus( + " ...serializing %zu %s objects", size_t(objPool.size()), poolName); + + auto &childNode = objPoolRoot[poolName]; + foreach_item_const(objPool, [&](const auto *obj) { + if (!obj) + return; + auto &m = childNode.append(); + objectToNode(*obj, m, forceProxyArrays); + }); +} + // Parameters ///////////////////////////////////////////////////////////////// void parameterToNode(const Parameter &p, core::DataNode &node) @@ -296,6 +425,7 @@ void cameraPoseToNode(const rendering::CameraPose &p, core::DataNode &node) node["azeldist"] = p.azeldist; node["fixedDist"] = p.fixedDist; node["upAxis"] = p.upAxis; + node["mode"] = p.mode; } void nodeToCameraPose(core::DataNode &node, rendering::CameraPose &pose) @@ -305,6 +435,7 @@ void nodeToCameraPose(core::DataNode &node, rendering::CameraPose &pose) node["azeldist"].getValue(ANARI_FLOAT32_VEC3, &pose.azeldist); node["fixedDist"].getValue(ANARI_FLOAT32, &pose.fixedDist); node["upAxis"].getValue(ANARI_INT32, &pose.upAxis); + node["mode"].getValue(ANARI_INT32, &pose.mode); } // Layers ///////////////////////////////////////////////////////////////////// @@ -532,6 +663,12 @@ void save_Scene(Scene &scene, bool forceProxyArrays, tsd::animation::AnimationManager *animMgr) { + core::writeDataTreeMetadata(root, + {core::DATA_TREE_METADATA_ENVELOPE_VERSION, + "scene", + std::string(schema::SCENE_FULL), + 1}); + scene.defragmentObjectStorage(); // ensure contiguous object indices // Layers // @@ -550,34 +687,16 @@ void save_Scene(Scene &scene, // ObjectDB // auto &objectDB = root["objectDB"]; - auto objectPoolToNode = [&](core::DataNode &objPoolRoot, - const auto &objPool, - const char *poolName) { - if (objPool.empty()) - return; - - tsd::core::logStatus( - " ...serializing %zu %s objects", size_t(objPool.size()), poolName); - - auto &childNode = objPoolRoot[poolName]; - foreach_item_const(objPool, [&](const auto *obj) { - if (!obj) - return; - auto &m = childNode.append(); - objectToNode(*obj, m, forceProxyArrays); - }); - }; - - objectPoolToNode(objectDB, scene.m_db.geometry, "geometry"); - objectPoolToNode(objectDB, scene.m_db.sampler, "sampler"); - objectPoolToNode(objectDB, scene.m_db.material, "material"); - objectPoolToNode(objectDB, scene.m_db.surface, "surface"); - objectPoolToNode(objectDB, scene.m_db.field, "spatialfield"); - objectPoolToNode(objectDB, scene.m_db.volume, "volume"); - objectPoolToNode(objectDB, scene.m_db.light, "light"); - objectPoolToNode(objectDB, scene.m_db.camera, "camera"); - objectPoolToNode(objectDB, scene.m_db.renderer, "renderer"); - objectPoolToNode(objectDB, scene.m_db.array, "array"); + objectPoolToNode(objectDB, scene.m_db.geometry, "geometry", forceProxyArrays); + objectPoolToNode(objectDB, scene.m_db.sampler, "sampler", forceProxyArrays); + objectPoolToNode(objectDB, scene.m_db.material, "material", forceProxyArrays); + objectPoolToNode(objectDB, scene.m_db.surface, "surface", forceProxyArrays); + objectPoolToNode(objectDB, scene.m_db.field, "spatialfield", forceProxyArrays); + objectPoolToNode(objectDB, scene.m_db.volume, "volume", forceProxyArrays); + objectPoolToNode(objectDB, scene.m_db.light, "light", forceProxyArrays); + objectPoolToNode(objectDB, scene.m_db.camera, "camera", forceProxyArrays); + objectPoolToNode(objectDB, scene.m_db.renderer, "renderer", forceProxyArrays); + objectPoolToNode(objectDB, scene.m_db.array, "array", forceProxyArrays); // Animations // @@ -585,6 +704,32 @@ void save_Scene(Scene &scene, animationManagerToNode(*animMgr, root["animations"]); } +void save_SceneCamerasAndRenderers(Scene &scene, const char *filename) +{ + tsd::core::logStatus( + "Saving scene cameras and renderers to file: %s", filename); + core::DataTree tree; + save_SceneCamerasAndRenderers(scene, tree.root()); + if (!tree.save(filename)) + tsd::core::logError( + "[save_SceneCamerasAndRenderers] failed to write file '%s'", filename); +} + +void save_SceneCamerasAndRenderers(Scene &scene, core::DataNode &root) +{ + root.reset(); + core::writeDataTreeMetadata(root, + {core::DATA_TREE_METADATA_ENVELOPE_VERSION, + "scene-subset", + std::string(schema::SCENE_CAMERAS_AND_RENDERERS), + 1}); + scene.defragmentObjectStorage(); // ensure contiguous object indices + + auto &objectDB = root["objectDB"]; + objectPoolToNode(objectDB, scene.m_db.camera, "camera", false); + objectPoolToNode(objectDB, scene.m_db.renderer, "renderer", false); +} + void load_Scene(Scene &scene, const char *filename, tsd::animation::AnimationManager *animMgr) @@ -592,19 +737,43 @@ void load_Scene(Scene &scene, tsd::core::logStatus("Loading context from file: %s", filename); tsd::core::logStatus(" ...loading file"); core::DataTree tree; - tree.load(filename); - auto &root = tree.root(); - if (auto *c = root.child("context"); c != nullptr) - load_Scene(scene, *c, animMgr); - else - load_Scene(scene, root, animMgr); + if (!tree.load(filename)) { + tsd::core::logError("[load_Scene] failed to load file '%s'", filename); + return; + } + load_Scene(scene, tree.root(), animMgr); } void load_Scene(Scene &scene, core::DataNode &root, tsd::animation::AnimationManager *animMgr) +{ + PayloadValidationResult result; + tryLoad_Scene(scene, root, &result, animMgr); + if (!result.accepted()) + logValidationFailure("load_Scene", result); +} + +PayloadValidationResult validate_ScenePayload(core::DataNode &root) +{ + return validateScenePayloadImpl( + root, {schema::SCENE_FULL}, {schema::SCENE_FULL, + schema::SCENE_CAMERAS_AND_RENDERERS}); +} + +bool tryLoad_Scene(Scene &scene, + core::DataNode &root, + PayloadValidationResult *resultOut, + tsd::animation::AnimationManager *animMgr) { // Clear out any existing context contents // + auto result = validate_ScenePayload(root); + if (resultOut) + *resultOut = result; + if (!result.accepted()) + return false; + + auto &payloadRoot = resolveScenePayloadRoot(root); tsd::core::logStatus(" ...clearing old context"); @@ -616,7 +785,7 @@ void load_Scene(Scene &scene, tsd::core::logStatus(" ...converting objects"); - auto &objectDB = root["objectDB"]; + auto &objectDB = payloadRoot["objectDB"]; auto nodeToObjectPool = [](core::DataNode &node, Scene &scene, const char *childNodeName) { auto &objectsNode = node[childNodeName]; @@ -638,7 +807,7 @@ void load_Scene(Scene &scene, tsd::core::logStatus(" ...converting layers"); - auto &layerRoot = root["layers"]; + auto &layerRoot = payloadRoot["layers"]; layerRoot.foreach_child([&](auto &nLayer) { tsd::core::Token layerName = nLayer.name().c_str(); auto &tLayer = *scene.addLayer(layerName); @@ -660,9 +829,82 @@ void load_Scene(Scene &scene, // Animations if (animMgr) - nodeToAnimationManager(root["animations"], *animMgr, scene); + nodeToAnimationManager(payloadRoot["animations"], *animMgr, scene); + + tsd::core::logStatus(" ...done!"); + return true; +} + +void load_SceneCamerasAndRenderers(Scene &scene, const char *filename) +{ + tsd::core::logStatus( + "Loading scene cameras and renderers from file: %s", filename); + core::DataTree tree; + if (!tree.load(filename)) { + tsd::core::logError( + "[load_SceneCamerasAndRenderers] failed to load file '%s'", filename); + return; + } + + load_SceneCamerasAndRenderers(scene, tree.root()); +} + +void load_SceneCamerasAndRenderers(Scene &scene, core::DataNode &root) +{ + PayloadValidationResult result; + tryLoad_SceneCamerasAndRenderers(scene, root, &result); + if (!result.accepted()) + logValidationFailure("load_SceneCamerasAndRenderers", result); +} + +PayloadValidationResult validate_SceneCamerasAndRenderersPayload( + core::DataNode &root) +{ + return validateScenePayloadImpl(root, + {schema::SCENE_CAMERAS_AND_RENDERERS, schema::SCENE_FULL}, + {schema::SCENE_FULL, schema::SCENE_CAMERAS_AND_RENDERERS}); +} + +bool tryLoad_SceneCamerasAndRenderers(Scene &scene, + core::DataNode &root, + PayloadValidationResult *resultOut) +{ + auto result = validate_SceneCamerasAndRenderersPayload(root); + if (resultOut) + *resultOut = result; + if (!result.accepted()) + return false; + + auto &payloadRoot = resolveScenePayloadRoot(root); + + auto removeObjects = [&](auto &pool) { + for (size_t i = pool.capacity(); i-- > 0;) { + auto obj = pool.at(i); + if (obj) + scene.removeObject(obj.data()); + } + }; + + scene.m_defaultObjects.camera.reset(); + removeObjects(scene.m_db.renderer); + removeObjects(scene.m_db.camera); + + auto &objectDB = payloadRoot["objectDB"]; + auto nodeToObjectPool = [](core::DataNode &node, + Scene &scene, + const char *childNodeName) { + auto &objectsNode = node[childNodeName]; + objectsNode.foreach_child([&](auto &n) { nodeToNewObject(scene, n); }); + }; + + nodeToObjectPool(objectDB, scene, "camera"); + nodeToObjectPool(objectDB, scene, "renderer"); + + scene.m_defaultObjects.camera.reset(); + scene.defaultCamera(); tsd::core::logStatus(" ...done!"); + return true; } } // namespace tsd::io diff --git a/tsd/src/tsd/io/serialization/serialization_object.cpp b/tsd/src/tsd/io/serialization/serialization_object.cpp new file mode 100644 index 000000000..19e0bcdc5 --- /dev/null +++ b/tsd/src/tsd/io/serialization/serialization_object.cpp @@ -0,0 +1,1208 @@ +// Copyright 2024-2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "tsd/io/serialization.hpp" +#include "tsd/core/DataTreeMetadata.hpp" +#include "tsd/core/DataTree.hpp" +#include "tsd/core/Logging.hpp" +// std +#include +#include +#include +#include +#include + +namespace tsd::io { + +namespace { + +using tsd::core::Any; + +struct ObjectKey +{ + anari::DataType type{ANARI_UNKNOWN}; + size_t index{tsd::core::INVALID_INDEX}; +}; + +struct ClosureEntry +{ + ObjectKey source; + anari::DataType objectType{ANARI_UNKNOWN}; + size_t localIndex{tsd::core::INVALID_INDEX}; + Object *object{nullptr}; +}; + +struct FileObjectEntry +{ + ObjectKey file; + anari::DataType objectType{ANARI_UNKNOWN}; + core::DataNode *node{nullptr}; +}; + +struct TargetObjectEntry +{ + ObjectKey file; + Any target; +}; + +constexpr std::array OBJECT_POOL_NAMES = { + "array", + "sampler", + "material", + "geometry", + "surface", + "spatialfield", + "volume", +}; + +anari::DataType canonicalObjectType(anari::DataType type) +{ + return anari::isArray(type) ? ANARI_ARRAY : type; +} + +ObjectKey makeKey(anari::DataType type, size_t index) +{ + return {canonicalObjectType(type), index}; +} + +ObjectKey makeKey(const Any &value) +{ + return makeKey(value.type(), value.getAsObjectIndex()); +} + +bool sameKey(const ObjectKey &a, const ObjectKey &b) +{ + return a.type == b.type && a.index == b.index; +} + +anari::DataType nonArrayTypeForPoolName(std::string_view name) +{ + if (name == "sampler") + return ANARI_SAMPLER; + if (name == "material") + return ANARI_MATERIAL; + if (name == "geometry") + return ANARI_GEOMETRY; + if (name == "surface") + return ANARI_SURFACE; + if (name == "spatialfield") + return ANARI_SPATIAL_FIELD; + if (name == "volume") + return ANARI_VOLUME; + return ANARI_UNKNOWN; +} + +bool isKnownObjectPoolName(std::string_view name) +{ + return std::find(OBJECT_POOL_NAMES.begin(), OBJECT_POOL_NAMES.end(), name) + != OBJECT_POOL_NAMES.end(); +} + +bool poolAllowedForRoot(anari::DataType rootType, std::string_view poolName) +{ + if (poolName == "array" || poolName == "sampler") + return true; + + if (rootType == ANARI_SURFACE) + return poolName == "surface" || poolName == "geometry" + || poolName == "material"; + + if (rootType == ANARI_VOLUME) + return poolName == "volume" || poolName == "spatialfield"; + + return false; +} + +bool typeAllowedForRoot(anari::DataType rootType, anari::DataType type) +{ + if (anari::isArray(type) || type == ANARI_SAMPLER) + return true; + + if (rootType == ANARI_SURFACE) + return type == ANARI_SURFACE || type == ANARI_GEOMETRY + || type == ANARI_MATERIAL; + + if (rootType == ANARI_VOLUME) + return type == ANARI_VOLUME || type == ANARI_SPATIAL_FIELD; + + return false; +} + +size_t *counterForType(anari::DataType type, + size_t &arrays, + size_t &samplers, + size_t &materials, + size_t &geometries, + size_t &surfaces, + size_t &fields, + size_t &volumes) +{ + switch (canonicalObjectType(type)) { + case ANARI_ARRAY: + return &arrays; + case ANARI_SAMPLER: + return &samplers; + case ANARI_MATERIAL: + return &materials; + case ANARI_GEOMETRY: + return &geometries; + case ANARI_SURFACE: + return &surfaces; + case ANARI_SPATIAL_FIELD: + return &fields; + case ANARI_VOLUME: + return &volumes; + default: + return nullptr; + } +} + +ClosureEntry *findEntry(std::vector &entries, const ObjectKey &key) +{ + auto it = std::find_if(entries.begin(), entries.end(), [&](auto &entry) { + return sameKey(entry.source, key); + }); + return it == entries.end() ? nullptr : &*it; +} + +const ClosureEntry *findEntry( + const std::vector &entries, const ObjectKey &key) +{ + auto it = std::find_if(entries.begin(), entries.end(), [&](auto &entry) { + return sameKey(entry.source, key); + }); + return it == entries.end() ? nullptr : &*it; +} + +FileObjectEntry *findEntry( + std::vector &entries, const ObjectKey &key) +{ + auto it = std::find_if(entries.begin(), entries.end(), [&](auto &entry) { + return sameKey(entry.file, key); + }); + return it == entries.end() ? nullptr : &*it; +} + +const FileObjectEntry *findEntry( + const std::vector &entries, const ObjectKey &key) +{ + auto it = std::find_if(entries.begin(), entries.end(), [&](auto &entry) { + return sameKey(entry.file, key); + }); + return it == entries.end() ? nullptr : &*it; +} + +const TargetObjectEntry *findEntry( + const std::vector &entries, const ObjectKey &key) +{ + auto it = std::find_if(entries.begin(), entries.end(), [&](auto &entry) { + return sameKey(entry.file, key); + }); + return it == entries.end() ? nullptr : &*it; +} + +bool hasObjectArrayNode(core::DataNode &node, std::string *message) +{ + bool found = false; + node.traverse([&](core::DataNode &n, int) { + if (n.holdsArray() && anari::isObject(n.arrayType())) { + if (message) { + *message = "object array values are not supported in object files"; + } + found = true; + return false; + } + return true; + }); + return found; +} + +bool addReferencedObject(const Scene &scene, + anari::DataType rootType, + const ObjectKey &rootKey, + const Any &value, + std::vector &entries, + size_t &arrays, + size_t &samplers, + size_t &materials, + size_t &geometries, + size_t &surfaces, + size_t &fields, + size_t &volumes, + std::string &errorMessage) +{ + if (!value.holdsObject()) + return true; + + const auto key = makeKey(value); + if (findEntry(entries, key)) + return true; + + auto *object = scene.getObject(value.type(), value.getAsObjectIndex()); + if (!object) { + errorMessage = "referenced object "; + errorMessage += anari::toString(value.type()); + errorMessage += " @"; + errorMessage += std::to_string(value.getAsObjectIndex()); + errorMessage += " is missing"; + return false; + } + + const auto objectType = object->type(); + if (!typeAllowedForRoot(rootType, objectType)) { + errorMessage = "object closure reached unsupported type "; + errorMessage += anari::toString(objectType); + return false; + } + + if ((objectType == ANARI_SURFACE || objectType == ANARI_VOLUME) + && !sameKey(key, rootKey)) { + errorMessage = "object files support only one root "; + errorMessage += anari::toString(rootType); + return false; + } + + if (anari::isArray(objectType)) { + auto *array = static_cast(object); + if (array->isProxy()) { + errorMessage = "object files cannot export proxy arrays"; + return false; + } + if (anari::isObject(array->elementType())) { + errorMessage = "object files cannot export arrays of ANARI objects"; + return false; + } + } + + auto *counter = counterForType( + objectType, arrays, samplers, materials, geometries, surfaces, fields, volumes); + if (!counter) { + errorMessage = "object closure reached unsupported type "; + errorMessage += anari::toString(objectType); + return false; + } + + entries.push_back({key, objectType, (*counter)++, object}); + return true; +} + +bool addReferencesFromAny(const Scene &scene, + anari::DataType rootType, + const ObjectKey &rootKey, + const Any &value, + std::vector &entries, + size_t &arrays, + size_t &samplers, + size_t &materials, + size_t &geometries, + size_t &surfaces, + size_t &fields, + size_t &volumes, + std::string &errorMessage) +{ + if (!value.holdsObject()) + return true; + + return addReferencedObject(scene, + rootType, + rootKey, + value, + entries, + arrays, + samplers, + materials, + geometries, + surfaces, + fields, + volumes, + errorMessage); +} + +bool buildExportClosure(const Object &root, + std::vector &entries, + std::string &errorMessage) +{ + const auto rootType = root.type(); + auto *scene = root.scene(); + if (!scene) { + errorMessage = "root object has no owning Scene"; + return false; + } + + size_t arrays = 0; + size_t samplers = 0; + size_t materials = 0; + size_t geometries = 0; + size_t surfaces = 0; + size_t fields = 0; + size_t volumes = 0; + + const auto rootKey = makeKey(rootType, root.index()); + entries.push_back({rootKey, rootType, 0, const_cast(&root)}); + if (rootType == ANARI_SURFACE) + surfaces = 1; + else if (rootType == ANARI_VOLUME) + volumes = 1; + + for (size_t i = 0; i < entries.size(); i++) { + auto *object = entries[i].object; + + if (anari::isArray(object->type())) { + auto *array = static_cast(object); + if (array->isProxy()) { + errorMessage = "object files cannot export proxy arrays"; + return false; + } + if (anari::isObject(array->elementType())) { + errorMessage = "object files cannot export arrays of ANARI objects"; + return false; + } + } + + core::DataTree scratchTree; + objectToNode(*object, scratchTree.root(), false); + if (hasObjectArrayNode(scratchTree.root(), &errorMessage)) + return false; + + for (size_t p = 0; p < object->numParameters(); p++) { + const auto ¶m = object->parameterAt(p); + if (!addReferencesFromAny(*scene, + rootType, + rootKey, + param.value(), + entries, + arrays, + samplers, + materials, + geometries, + surfaces, + fields, + volumes, + errorMessage)) + return false; + if (param.hasMin() + && !addReferencesFromAny(*scene, + rootType, + rootKey, + param.min(), + entries, + arrays, + samplers, + materials, + geometries, + surfaces, + fields, + volumes, + errorMessage)) + return false; + if (param.hasMax() + && !addReferencesFromAny(*scene, + rootType, + rootKey, + param.max(), + entries, + arrays, + samplers, + materials, + geometries, + surfaces, + fields, + volumes, + errorMessage)) + return false; + } + + for (size_t m = 0; m < object->numMetadata(); m++) { + const auto *name = object->getMetadataName(m); + anari::DataType arrayType = ANARI_UNKNOWN; + const void *arrayPtr = nullptr; + size_t arraySize = 0; + object->getMetadataArray(name, &arrayType, &arrayPtr, &arraySize); + if (anari::isObject(arrayType)) { + errorMessage = "object-valued metadata arrays are not supported"; + return false; + } + if (arrayType != ANARI_UNKNOWN) + continue; + + if (!addReferencesFromAny(*scene, + rootType, + rootKey, + object->getMetadataValue(name), + entries, + arrays, + samplers, + materials, + geometries, + surfaces, + fields, + volumes, + errorMessage)) + return false; + } + } + + return true; +} + +bool rewriteObjectReferences(core::DataNode &root, + const std::vector &entries, + std::string &errorMessage) +{ + bool ok = true; + root.traverse([&](core::DataNode &node, int) { + if (!ok) + return false; + + if (node.holdsArray() && anari::isObject(node.arrayType())) { + errorMessage = "object array values are not supported in object files"; + ok = false; + return false; + } + + if (!node.holdsObjectIdx()) + return true; + + anari::DataType type = ANARI_UNKNOWN; + size_t index = tsd::core::INVALID_INDEX; + node.getValueAsObjectIdx(&type, &index); + + auto *entry = findEntry(entries, makeKey(type, index)); + if (!entry) { + errorMessage = "serialized object reference has no closure mapping"; + ok = false; + return false; + } + + node.setValue(Any(type, entry->localIndex)); + return true; + }); + return ok; +} + +bool rewriteObjectReferences(core::DataNode &root, + const std::vector &entries, + std::string &errorMessage) +{ + bool ok = true; + root.traverse([&](core::DataNode &node, int) { + if (!ok) + return false; + + if (node.holdsArray() && anari::isObject(node.arrayType())) { + errorMessage = "object array values are not supported in object files"; + ok = false; + return false; + } + + if (!node.holdsObjectIdx()) + return true; + + anari::DataType type = ANARI_UNKNOWN; + size_t index = tsd::core::INVALID_INDEX; + node.getValueAsObjectIdx(&type, &index); + + auto *entry = findEntry(entries, makeKey(type, index)); + if (!entry) { + errorMessage = "serialized object reference has no import mapping"; + ok = false; + return false; + } + + node.setValue(Any(type, entry->target.getAsObjectIndex())); + return true; + }); + return ok; +} + +const ClosureEntry *entryForLocalIndex(const std::vector &entries, + anari::DataType type, + size_t localIndex) +{ + auto it = std::find_if(entries.begin(), entries.end(), [&](auto &entry) { + return canonicalObjectType(entry.objectType) == canonicalObjectType(type) + && entry.localIndex == localIndex; + }); + return it == entries.end() ? nullptr : &*it; +} + +PayloadValidationResult makeMetadataFailure( + PayloadValidationStatus status, std::string message) +{ + PayloadValidationResult result; + result.status = status; + result.message = std::move(message); + return result; +} + +PayloadValidationResult validateObjectMetadata(core::DataNode &root, + const std::vector &acceptedSchemas) +{ + auto metadataResult = core::readDataTreeMetadata(root); + if (metadataResult.malformed()) { + auto result = makeMetadataFailure( + PayloadValidationStatus::MalformedMetadata, metadataResult.message); + return result; + } + + if (!metadataResult.found()) { + return makeMetadataFailure(PayloadValidationStatus::MissingRequiredNode, + "object payload requires __tsd_metadata"); + } + + PayloadValidationResult result; + const auto &metadata = *metadataResult.metadata; + result.fileType = metadata.fileType; + result.schema = metadata.schema; + result.envelopeVersion = metadata.envelopeVersion; + result.schemaVersion = metadata.schemaVersion; + + if (metadata.envelopeVersion != core::DATA_TREE_METADATA_ENVELOPE_VERSION) { + result.status = PayloadValidationStatus::UnsupportedEnvelopeVersion; + result.message = "expected envelopeVersion 1, got " + + std::to_string(metadata.envelopeVersion); + return result; + } + + if (metadata.fileType != "object") { + result.status = PayloadValidationStatus::IncompatibleSchema; + result.message = "fileType '" + metadata.fileType + + "' is not accepted by object import"; + return result; + } + + const auto schemaMatches = [&](std::string_view schema) { + return metadata.schema == schema; + }; + + if (std::none_of( + acceptedSchemas.begin(), acceptedSchemas.end(), schemaMatches)) { + result.status = + metadata.schema == schema::OBJECT_SURFACE + || metadata.schema == schema::OBJECT_VOLUME + || metadata.schema == schema::SCENE_FULL + || metadata.schema == schema::SCENE_CAMERAS_AND_RENDERERS + ? PayloadValidationStatus::IncompatibleSchema + : PayloadValidationStatus::UnknownSchema; + result.message = + "schema '" + metadata.schema + "' is not accepted by this loader"; + return result; + } + + if (metadata.schemaVersion != 1) { + result.status = PayloadValidationStatus::UnsupportedSchemaVersion; + result.message = "schema '" + metadata.schema + + "' supports version 1..1, got " + + std::to_string(metadata.schemaVersion); + return result; + } + + return result; +} + +anari::DataType rootTypeForSchema(std::string_view schemaName) +{ + if (schemaName == schema::OBJECT_SURFACE) + return ANARI_SURFACE; + if (schemaName == schema::OBJECT_VOLUME) + return ANARI_VOLUME; + return ANARI_UNKNOWN; +} + +std::string schemaForRootType(anari::DataType rootType) +{ + if (rootType == ANARI_SURFACE) + return std::string(schema::OBJECT_SURFACE); + if (rootType == ANARI_VOLUME) + return std::string(schema::OBJECT_VOLUME); + return {}; +} + +bool collectFileObjects(core::DataNode &objectDB, + std::vector &entries, + PayloadValidationResult &result) +{ + for (auto poolName : OBJECT_POOL_NAMES) { + auto *poolNode = objectDB.child(poolName); + if (!poolNode) + continue; + + const auto expectedType = nonArrayTypeForPoolName(poolName); + size_t expectedIndex = 0; + bool ok = true; + poolNode->foreach_child([&](core::DataNode &objectNode) { + if (!ok) + return; + + auto *selfNode = objectNode.child("self"); + if (!selfNode || !selfNode->holdsObjectIdx()) { + result.status = PayloadValidationStatus::MalformedMetadata; + result.message = std::string("objectDB/") + poolName + + " entry is missing object self"; + ok = false; + return; + } + + anari::DataType objectType = ANARI_UNKNOWN; + size_t index = tsd::core::INVALID_INDEX; + selfNode->getValueAsObjectIdx(&objectType, &index); + + if (std::string_view(poolName) == "array") { + if (!anari::isArray(objectType)) { + result.status = PayloadValidationStatus::MalformedMetadata; + result.message = "objectDB/array entry has non-array self type"; + ok = false; + return; + } + } else if (objectType != expectedType) { + result.status = PayloadValidationStatus::MalformedMetadata; + result.message = std::string("objectDB/") + poolName + + " entry self type does not match its pool"; + ok = false; + return; + } + + if (index != expectedIndex) { + result.status = PayloadValidationStatus::MalformedMetadata; + result.message = std::string("objectDB/") + poolName + + " entries must use dense local indices"; + ok = false; + return; + } + + auto *subtypeNode = objectNode.child("subtype"); + if (!subtypeNode || subtypeNode->getValue().type() != ANARI_STRING) { + result.status = PayloadValidationStatus::MalformedMetadata; + result.message = std::string("objectDB/") + poolName + + " entry is missing subtype"; + ok = false; + return; + } + + if (anari::isArray(objectType)) { + auto *arrayDim = objectNode.child("arrayDim"); + auto *arrayData = objectNode.child("arrayData"); + if (!arrayDim || !arrayData) { + result.status = PayloadValidationStatus::MissingRequiredNode; + result.message = "array entries require arrayDim and arrayData"; + ok = false; + return; + } + if (arrayDim->getValue().type() != ANARI_UINT32_VEC3) { + result.status = PayloadValidationStatus::MalformedMetadata; + result.message = "arrayDim must be uint3"; + ok = false; + return; + } + if (!arrayData->holdsArray()) { + result.status = PayloadValidationStatus::MalformedMetadata; + result.message = "object files cannot import proxy arrays"; + ok = false; + return; + } + if (anari::isObject(arrayData->arrayType())) { + result.status = PayloadValidationStatus::MalformedMetadata; + result.message = "object files cannot import arrays of ANARI objects"; + ok = false; + return; + } + + auto dim = arrayDim->getValueAs(); + const bool is2D = objectType == ANARI_ARRAY2D; + const bool is3D = objectType == ANARI_ARRAY3D; + const size_t expectedArraySize = + size_t(dim[0]) * (is2D || is3D ? size_t(dim[1]) : size_t(1)) + * (is3D ? size_t(dim[2]) : size_t(1)); + + anari::DataType arrayElementType = ANARI_UNKNOWN; + const void *arrayPtr = nullptr; + size_t arraySize = 0; + arrayData->getValueAsArray(&arrayElementType, &arrayPtr, &arraySize); + if (arraySize != expectedArraySize) { + result.status = PayloadValidationStatus::MalformedMetadata; + result.message = "arrayData size does not match arrayDim"; + ok = false; + return; + } + } + + const auto key = makeKey(objectType, index); + if (findEntry(entries, key)) { + result.status = PayloadValidationStatus::MalformedMetadata; + result.message = "objectDB contains duplicate object indices"; + ok = false; + return; + } + + entries.push_back({key, objectType, &objectNode}); + expectedIndex++; + }); + + if (!ok) + return false; + } + + return true; +} + +bool validateObjectGraph(core::DataNode &root, + anari::DataType rootType, + std::vector &entries, + PayloadValidationResult &result) +{ + auto *objectDB = root.child("objectDB"); + if (!objectDB) { + result.status = PayloadValidationStatus::MissingRequiredNode; + result.message = "object payload requires objectDB"; + return false; + } + + auto *rootObject = root.child("rootObject"); + if (!rootObject || !rootObject->holdsObjectIdx()) { + result.status = PayloadValidationStatus::MissingRequiredNode; + result.message = "object payload requires rootObject"; + return false; + } + + anari::DataType declaredRootType = ANARI_UNKNOWN; + size_t declaredRootIndex = tsd::core::INVALID_INDEX; + rootObject->getValueAsObjectIdx(&declaredRootType, &declaredRootIndex); + if (declaredRootType != rootType || declaredRootIndex != 0) { + result.status = PayloadValidationStatus::IncompatibleSchema; + result.message = "rootObject must match schema type at local index 0"; + return false; + } + + bool ok = true; + objectDB->foreach_child([&](core::DataNode &poolNode) { + if (!ok) + return; + if (!isKnownObjectPoolName(poolNode.name()) && poolNode.numChildren() > 0) { + result.status = PayloadValidationStatus::IncompatibleSchema; + result.message = "object payload contains unsupported object pool '" + + poolNode.name() + "'"; + ok = false; + return; + } + if (isKnownObjectPoolName(poolNode.name()) + && !poolAllowedForRoot(rootType, poolNode.name()) + && poolNode.numChildren() > 0) { + result.status = PayloadValidationStatus::IncompatibleSchema; + result.message = "object payload contains disallowed pool '" + + poolNode.name() + "'"; + ok = false; + } + }); + if (!ok) + return false; + + if (!collectFileObjects(*objectDB, entries, result)) + return false; + + const auto rootKey = makeKey(rootType, 0); + if (!findEntry(entries, rootKey)) { + result.status = PayloadValidationStatus::MissingRequiredNode; + result.message = "rootObject entry is missing from objectDB"; + return false; + } + + size_t rootCount = 0; + for (const auto &entry : entries) { + if (!typeAllowedForRoot(rootType, entry.objectType)) { + result.status = PayloadValidationStatus::IncompatibleSchema; + result.message = "object payload contains unsupported object type "; + result.message += anari::toString(entry.objectType); + return false; + } + + if (entry.objectType == rootType) + rootCount++; + } + + if (rootCount != 1) { + result.status = PayloadValidationStatus::IncompatibleSchema; + result.message = "object payload must contain exactly one root object"; + return false; + } + + std::vector reachable; + reachable.push_back(rootKey); + + for (size_t cursor = 0; cursor < reachable.size(); cursor++) { + auto *entry = findEntry(entries, reachable[cursor]); + if (!entry) + continue; + + bool traversalOK = true; + entry->node->traverse([&](core::DataNode &node, int) { + if (!traversalOK) + return false; + + if (node.holdsArray() && anari::isObject(node.arrayType())) { + result.status = PayloadValidationStatus::MalformedMetadata; + result.message = + "object array values are not supported in object files"; + traversalOK = false; + return false; + } + + if (!node.holdsObjectIdx()) + return true; + + anari::DataType refType = ANARI_UNKNOWN; + size_t refIndex = tsd::core::INVALID_INDEX; + node.getValueAsObjectIdx(&refType, &refIndex); + auto refKey = makeKey(refType, refIndex); + if (!findEntry(entries, refKey)) { + result.status = PayloadValidationStatus::MalformedMetadata; + result.message = "object payload references missing object "; + result.message += anari::toString(refType); + result.message += " @"; + result.message += std::to_string(refIndex); + traversalOK = false; + return false; + } + + if (std::none_of(reachable.begin(), reachable.end(), [&](auto &key) { + return sameKey(key, refKey); + })) + reachable.push_back(refKey); + + return true; + }); + + if (!traversalOK) + return false; + } + + for (const auto &entry : entries) { + if (std::none_of(reachable.begin(), reachable.end(), [&](auto &key) { + return sameKey(key, entry.file); + })) { + result.status = PayloadValidationStatus::IncompatibleSchema; + result.message = "object payload contains unreferenced objects"; + return false; + } + } + + return true; +} + +PayloadValidationResult validateObjectPayloadImpl(core::DataNode &root, + const std::vector &acceptedSchemas) +{ + auto result = validateObjectMetadata(root, acceptedSchemas); + if (!result.accepted()) + return result; + + const auto rootType = rootTypeForSchema(result.schema); + std::vector entries; + validateObjectGraph(root, rootType, entries, result); + return result; +} + +Object *createTargetObject(Scene &scene, core::DataNode &node) +{ + const auto self = node["self"].getValue(); + const auto type = self.type(); + const Token subtype(node["subtype"].getValueAs()); + + if (anari::isArray(type)) { + auto &arrayData = node["arrayData"]; + auto dim = node["arrayDim"].getValueAs(); + + const bool is2D = type == ANARI_ARRAY2D; + const bool is3D = type == ANARI_ARRAY3D; + const size_t dim_x = dim[0]; + const size_t dim_y = is2D || is3D ? dim[1] : size_t(0); + const size_t dim_z = is3D ? dim[2] : size_t(0); + + anari::DataType arrayElementType = ANARI_UNKNOWN; + const void *arrayPtr = nullptr; + size_t arraySize = 0; + arrayData.getValueAsArray(&arrayElementType, &arrayPtr, &arraySize); + + auto array = scene.createArray(arrayElementType, dim_x, dim_y, dim_z); + if (!array) + return nullptr; + + const size_t expectedSize = array->size(); + if (arraySize != expectedSize) { + scene.removeObject(array.data()); + return nullptr; + } + + if (expectedSize > 0) { + auto *memOut = array->map(); + std::memcpy(memOut, arrayPtr, array->size() * array->elementSize()); + array->unmap(); + } + return array.data(); + } + + switch (type) { + case ANARI_GEOMETRY: + return scene.createObject(subtype).data(); + case ANARI_MATERIAL: + return scene.createObject(subtype).data(); + case ANARI_SAMPLER: + return scene.createObject(subtype).data(); + case ANARI_SURFACE: + return scene.createSurface().data(); + case ANARI_SPATIAL_FIELD: + return scene.createObject(subtype).data(); + case ANARI_VOLUME: + return scene.createObject(subtype).data(); + default: + break; + } + + return nullptr; +} + +void clearObjectPayload(Object &object) +{ + object.removeAllParameters(); + while (object.numMetadata() > 0) { + std::string name = object.getMetadataName(0); + object.removeMetadata(name); + } +} + +void rollbackCreatedObjects(Scene &scene, const std::vector &created) +{ + for (auto &ref : created) { + if (auto *object = scene.getObject(ref)) + clearObjectPayload(*object); + } + + for (auto it = created.rbegin(); it != created.rend(); ++it) + scene.removeObject(*it); +} + +Object *importObjectFromTree(Scene &scene, core::DataNode &root) +{ + auto result = validate_ObjectPayload(root); + if (!result.accepted()) { + tsd::core::logError( + "[import_Object] payload validation failed: %s", result.message.c_str()); + return nullptr; + } + + const auto rootType = rootTypeForSchema(result.schema); + std::vector fileEntries; + if (!validateObjectGraph(root, rootType, fileEntries, result)) { + tsd::core::logError( + "[import_Object] payload validation failed: %s", result.message.c_str()); + return nullptr; + } + + std::vector targetEntries; + std::vector createdRefs; + targetEntries.reserve(fileEntries.size()); + createdRefs.reserve(fileEntries.size()); + + try { + for (auto &fileEntry : fileEntries) { + auto *object = createTargetObject(scene, *fileEntry.node); + if (!object) { + rollbackCreatedObjects(scene, createdRefs); + tsd::core::logError("[import_Object] failed to create target object"); + return nullptr; + } + + clearObjectPayload(*object); + Any targetRef(object->type(), object->index()); + createdRefs.push_back(targetRef); + targetEntries.push_back({fileEntry.file, targetRef}); + } + + for (auto &fileEntry : fileEntries) { + auto *targetEntry = findEntry(targetEntries, fileEntry.file); + if (!targetEntry) { + rollbackCreatedObjects(scene, createdRefs); + tsd::core::logError("[import_Object] missing target object mapping"); + return nullptr; + } + + core::DataTree rewrittenTree; + rewrittenTree.root() = *fileEntry.node; + std::string errorMessage; + if (!rewriteObjectReferences( + rewrittenTree.root(), targetEntries, errorMessage)) { + rollbackCreatedObjects(scene, createdRefs); + tsd::core::logError( + "[import_Object] %s", errorMessage.c_str()); + return nullptr; + } + + auto *targetObject = scene.getObject(targetEntry->target); + if (!targetObject) { + rollbackCreatedObjects(scene, createdRefs); + tsd::core::logError("[import_Object] target object was removed"); + return nullptr; + } + + nodeToObject(rewrittenTree.root(), *targetObject); + } + } catch (const std::exception &e) { + rollbackCreatedObjects(scene, createdRefs); + tsd::core::logError("[import_Object] import failed: %s", e.what()); + return nullptr; + } + + const auto rootKey = makeKey(rootType, 0); + if (auto *entry = findEntry(targetEntries, rootKey)) + return scene.getObject(entry->target); + + return nullptr; +} + +} // namespace + +bool export_Object(const char *filename, const Object &obj) +{ + if (!filename) { + tsd::core::logError("[export_Object] filename is null"); + return false; + } + + if (obj.type() != ANARI_SURFACE && obj.type() != ANARI_VOLUME) { + tsd::core::logError("[export_Object] unsupported root object type '%s'", + anari::toString(obj.type())); + return false; + } + + std::vector entries; + std::string errorMessage; + if (!buildExportClosure(obj, entries, errorMessage)) { + tsd::core::logError("[export_Object] %s", errorMessage.c_str()); + return false; + } + + core::DataTree tree; + auto &root = tree.root(); + root.reset(); + + core::writeDataTreeMetadata(root, + {core::DATA_TREE_METADATA_ENVELOPE_VERSION, + "object", + schemaForRootType(obj.type()), + 1}); + + root["rootObject"] = Any(obj.type(), size_t(0)); + + auto &objectDB = root["objectDB"]; + for (auto poolName : OBJECT_POOL_NAMES) + objectDB[poolName]; + + for (auto poolName : OBJECT_POOL_NAMES) { + anari::DataType poolType = std::string_view(poolName) == "array" + ? ANARI_ARRAY + : nonArrayTypeForPoolName(poolName); + size_t localIndex = 0; + while (auto *entry = entryForLocalIndex(entries, poolType, localIndex++)) { + auto &node = objectDB[poolName].append(); + objectToNode(*entry->object, node, false); + if (!rewriteObjectReferences(node, entries, errorMessage)) { + tsd::core::logError("[export_Object] %s", errorMessage.c_str()); + return false; + } + } + } + + if (!tree.save(filename)) { + tsd::core::logError("[export_Object] failed to write file '%s'", filename); + return false; + } + + return true; +} + +Object *import_Object(Scene &scene, const char *filename) +{ + if (!filename) { + tsd::core::logError("[import_Object] filename is null"); + return nullptr; + } + + core::DataTree tree; + if (!tree.load(filename)) { + tsd::core::logError("[import_Object] failed to load file '%s'", filename); + return nullptr; + } + + return importObjectFromTree(scene, tree.root()); +} + +SurfaceRef import_Surface(Scene &scene, const char *filename) +{ + if (!filename) { + tsd::core::logError("[import_Surface] filename is null"); + return {}; + } + + core::DataTree tree; + if (!tree.load(filename)) { + tsd::core::logError("[import_Surface] failed to load file '%s'", filename); + return {}; + } + + auto result = validate_SurfacePayload(tree.root()); + if (!result.accepted()) { + tsd::core::logError("[import_Surface] payload validation failed: %s", + result.message.c_str()); + return {}; + } + + auto *object = importObjectFromTree(scene, tree.root()); + if (!object || object->type() != ANARI_SURFACE) + return {}; + + return scene.getObject(object->index()); +} + +VolumeRef import_Volume(Scene &scene, const char *filename) +{ + if (!filename) { + tsd::core::logError("[import_Volume] filename is null"); + return {}; + } + + core::DataTree tree; + if (!tree.load(filename)) { + tsd::core::logError("[import_Volume] failed to load file '%s'", filename); + return {}; + } + + auto result = validate_VolumePayload(tree.root()); + if (!result.accepted()) { + tsd::core::logError("[import_Volume] payload validation failed: %s", + result.message.c_str()); + return {}; + } + + auto *object = importObjectFromTree(scene, tree.root()); + if (!object || object->type() != ANARI_VOLUME) + return {}; + + return scene.getObject(object->index()); +} + +PayloadValidationResult validate_ObjectPayload(core::DataNode &root) +{ + return validateObjectPayloadImpl( + root, {schema::OBJECT_SURFACE, schema::OBJECT_VOLUME}); +} + +PayloadValidationResult validate_SurfacePayload(core::DataNode &root) +{ + return validateObjectPayloadImpl(root, {schema::OBJECT_SURFACE}); +} + +PayloadValidationResult validate_VolumePayload(core::DataNode &root) +{ + return validateObjectPayloadImpl(root, {schema::OBJECT_VOLUME}); +} + +} // namespace tsd::io diff --git a/tsd/src/tsd/rendering/CMakeLists.txt b/tsd/src/tsd/rendering/CMakeLists.txt index 7d6168202..ff2df4aaf 100644 --- a/tsd/src/tsd/rendering/CMakeLists.txt +++ b/tsd/src/tsd/rendering/CMakeLists.txt @@ -27,6 +27,7 @@ PRIVATE pipeline/passes/ToneMapPass.cpp pipeline/passes/VisualizeAOVPass.cpp pipeline/ImagePipeline.cpp + view/Manipulator.cpp view/ManipulatorToAnari.cpp view/ManipulatorToTSD.cpp ) diff --git a/tsd/src/tsd/rendering/view/CameraPath.h b/tsd/src/tsd/rendering/view/CameraPath.h index 3e09c3bc3..c02745690 100644 --- a/tsd/src/tsd/rendering/view/CameraPath.h +++ b/tsd/src/tsd/rendering/view/CameraPath.h @@ -93,6 +93,7 @@ inline CameraPose sampleCameraPathAt(const std::vector &poses, interpPose.azeldist = lerpAzElDist(localT, pose0.azeldist, pose1.azeldist); interpPose.fixedDist = lerp(localT, pose0.fixedDist, pose1.fixedDist); interpPose.upAxis = pose0.upAxis; + interpPose.mode = pose0.mode; return interpPose; } diff --git a/tsd/src/tsd/rendering/view/Manipulator.cpp b/tsd/src/tsd/rendering/view/Manipulator.cpp new file mode 100644 index 000000000..81b75a533 --- /dev/null +++ b/tsd/src/tsd/rendering/view/Manipulator.cpp @@ -0,0 +1,317 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "Manipulator.hpp" + +#include + +namespace tsd::rendering { + +void Manipulator::setConfig(const CameraPose &p) +{ + m_mode = static_cast(p.mode); + m_axis = static_cast(p.upAxis); + setConfig(p.lookat, p.azeldist.z, {p.azeldist.x, p.azeldist.y}); +} + +void Manipulator::setConfig( + anari::math::float3 center, float dist, anari::math::float2 azel) +{ + m_at = center; + m_distance = dist; + m_azel = azel; + m_speed = dist; + if (m_fixedDistance == tsd::math::inf) + m_fixedDistance = dist; + update(); +} + +void Manipulator::setCenter(anari::math::float3 center) +{ + if (m_mode == ManipulatorMode::Look) { + const auto eye = m_eye; + m_at = center; + const auto eyeToAt = eye - m_at; + const auto d = linalg::length(eyeToAt); + if (d > 0.f) { + m_distance = d; + m_speed = d; + m_azel = directionToAzel(linalg::normalize(eyeToAt), m_axis); + } + m_eye = eye; + update(); + return; + } + setConfig(center, m_distance, m_azel); +} + +void Manipulator::setDistance(float dist) +{ + m_distance = dist; + update(); +} + +void Manipulator::setFixedDistance(float dist) +{ + m_fixedDistance = dist; + update(); +} + +void Manipulator::setAzel(anari::math::float2 azel) +{ + m_azel = azel; + update(); +} + +void Manipulator::setMode(ManipulatorMode mode) +{ + if (m_mode == mode) + return; + + m_mode = mode; + m_token++; +} + +void Manipulator::setZoomSpeed(float speed) +{ + m_speed = speed; +} + +float Manipulator::zoomSpeed() const +{ + return m_speed; +} + +void Manipulator::startNewRotation() +{ + m_invertRotation = m_azel.y > 90.f && m_azel.y < 270.f; +} + +bool Manipulator::hasChanged(UpdateToken &t) const +{ + return tsd::core::versionChanged(t, m_token); +} + +void Manipulator::rotate(anari::math::float2 delta) +{ + delta *= 100; + if (m_axis == UpAxis::POS_Z || m_axis == UpAxis::NEG_X + || m_axis == UpAxis::NEG_Y) + delta.x = -delta.x; + delta.x = m_invertRotation ? -delta.x : delta.x; + delta.y = m_distance < 0.f ? -delta.y : delta.y; + m_azel += delta; + + auto maintainUnitCircle = [](float inDegrees) -> float { + while (inDegrees > 360.f) + inDegrees -= 360.f; + while (inDegrees < 0.f) + inDegrees += 360.f; + return inDegrees; + }; + + m_azel.x = maintainUnitCircle(m_azel.x); + m_azel.y = maintainUnitCircle(m_azel.y); + update(); +} + +void Manipulator::zoom(float delta) +{ + m_distance -= m_speed * delta; + update(); +} + +void Manipulator::pan(anari::math::float2 delta) +{ + delta *= m_speed; + + const anari::math::float3 amount = -delta.x * m_right + delta.y * m_up; + + m_eye += amount; + m_at += amount; + + update(); +} + +void Manipulator::setAxis(UpAxis axis) +{ + m_axis = axis; + update(); +} + +UpAxis Manipulator::axis() const +{ + return m_axis; +} + +ManipulatorMode Manipulator::mode() const +{ + return m_mode; +} + +anari::math::float2 Manipulator::azel() const +{ + return m_azel; +} + +anari::math::float3 Manipulator::eye() const +{ + return m_eye; +} + +anari::math::float3 Manipulator::at() const +{ + return m_at; +} + +anari::math::float3 Manipulator::dir() const +{ + return linalg::normalize(at() - eye()); +} + +anari::math::float3 Manipulator::up() const +{ + return m_up; +} + +float Manipulator::distance() const +{ + return m_distance; +} + +float Manipulator::fixedDistance() const +{ + return m_fixedDistance; +} + +anari::math::float3 Manipulator::eye_FixedDistance() const +{ + return m_eyeFixedDistance; +} + +void Manipulator::update() +{ + const float distance = std::abs(m_distance); + + const UpAxis axis = m_distance < 0.f ? negateAxis(m_axis) : m_axis; + + const float azimuth = tsd::math::radians(-m_azel.x); + const float elevation = tsd::math::radians(-m_azel.y); + + const anari::math::float3 toLocalManipulator = + azelToDirection(azimuth, elevation, axis); + + const anari::math::float3 localManipulatorPos = toLocalManipulator * distance; + const anari::math::float3 fromLocalManipulator = -localManipulatorPos; + + const anari::math::float3 alteredElevation = + azelToDirection(azimuth, elevation + 3, m_axis); + + const anari::math::float3 cameraRight = + linalg::cross(toLocalManipulator, alteredElevation); + const anari::math::float3 cameraUp = + linalg::cross(cameraRight, fromLocalManipulator); + + if (m_mode == ManipulatorMode::Look) + m_at = m_eye - localManipulatorPos; + else + m_eye = localManipulatorPos + m_at; + m_up = linalg::normalize(cameraUp); + m_right = linalg::normalize(cameraRight); + + m_eyeFixedDistance = (toLocalManipulator * m_fixedDistance) + m_at; + + m_token++; +} + +UpAxis Manipulator::negateAxis(UpAxis current) const +{ + switch (current) { + case UpAxis::POS_X: + return UpAxis::NEG_X; + case UpAxis::POS_Y: + return UpAxis::NEG_Y; + case UpAxis::POS_Z: + return UpAxis::NEG_Z; + case UpAxis::NEG_X: + return UpAxis::POS_X; + case UpAxis::NEG_Y: + return UpAxis::POS_Y; + case UpAxis::NEG_Z: + return UpAxis::POS_Z; + } + return {}; +} + +anari::math::float3 Manipulator::azelToDirection( + float az, float el, UpAxis axis) const +{ + const float x = std::sin(az) * std::cos(el); + const float y = std::cos(az) * std::cos(el); + const float z = std::sin(el); + switch (axis) { + case UpAxis::POS_X: + return -normalize(anari::math::float3(z, y, x)); + case UpAxis::POS_Y: + return -normalize(anari::math::float3(x, z, y)); + case UpAxis::POS_Z: + return -normalize(anari::math::float3(x, y, z)); + case UpAxis::NEG_X: + return normalize(anari::math::float3(z, y, x)); + case UpAxis::NEG_Y: + return normalize(anari::math::float3(x, z, y)); + case UpAxis::NEG_Z: + return normalize(anari::math::float3(x, y, z)); + } + return {}; +} + +anari::math::float2 Manipulator::directionToAzel( + anari::math::float3 direction, UpAxis axis) const +{ + float az = 0.f; + float el = 0.f; + + switch (axis) { + case UpAxis::POS_Y: { + const anari::math::float3 d = -direction; + el = std::asin(d.y); + az = std::atan2(d.x, d.z); + break; + } + case UpAxis::NEG_Y: { + const anari::math::float3 d = direction; + el = std::asin(d.y); + az = std::atan2(d.x, d.z); + break; + } + case UpAxis::POS_Z: { + const anari::math::float3 d = -direction; + el = std::asin(d.z); + az = std::atan2(d.x, d.y); + break; + } + case UpAxis::NEG_Z: { + const anari::math::float3 d = direction; + el = std::asin(d.z); + az = std::atan2(d.x, d.y); + break; + } + case UpAxis::POS_X: { + const anari::math::float3 d = -direction; + el = std::asin(d.x); + az = std::atan2(d.z, d.y); + break; + } + case UpAxis::NEG_X: { + const anari::math::float3 d = direction; + el = std::asin(d.x); + az = std::atan2(d.z, d.y); + break; + } + } + + return {-tsd::math::degrees(az), -tsd::math::degrees(el)}; +} + +} // namespace tsd::rendering diff --git a/tsd/src/tsd/rendering/view/Manipulator.hpp b/tsd/src/tsd/rendering/view/Manipulator.hpp index 225d3f0ee..69f044097 100644 --- a/tsd/src/tsd/rendering/view/Manipulator.hpp +++ b/tsd/src/tsd/rendering/view/Manipulator.hpp @@ -22,6 +22,12 @@ enum class UpAxis NEG_Z }; +enum class ManipulatorMode +{ + Orbit, + Look +}; + /* * Value type that captures a named camera viewpoint as az/el/distance * spherical coordinates relative to a look-at center, plus an up-axis. @@ -37,6 +43,7 @@ struct CameraPose tsd::math::float3 azeldist{0.f}; float fixedDist{tsd::math::inf}; int upAxis{static_cast(UpAxis::POS_Y)}; + int mode{static_cast(ManipulatorMode::Orbit)}; }; /* @@ -64,6 +71,7 @@ class Manipulator void setDistance(float dist); void setFixedDistance(float dist); void setAzel(anari::math::float2 azel); + void setMode(ManipulatorMode mode); void setZoomSpeed(float speed); float zoomSpeed() const; @@ -78,6 +86,7 @@ class Manipulator void setAxis(UpAxis axis); UpAxis axis() const; + ManipulatorMode mode() const; anari::math::float2 azel() const; @@ -96,6 +105,8 @@ class Manipulator UpAxis negateAxis(UpAxis current) const; anari::math::float3 azelToDirection(float az, float el, UpAxis axis) const; + anari::math::float2 directionToAzel( + anari::math::float3 direction, UpAxis axis) const; // Data // @@ -117,232 +128,7 @@ class Manipulator anari::math::float3 m_right; UpAxis m_axis{UpAxis::POS_Y}; + ManipulatorMode m_mode{ManipulatorMode::Orbit}; }; -// Inlined definitions //////////////////////////////////////////////////////// - -inline void Manipulator::setConfig(const CameraPose &p) -{ - setConfig(p.lookat, p.azeldist.z, {p.azeldist.x, p.azeldist.y}); - setAxis(static_cast(p.upAxis)); -} - -inline void Manipulator::setConfig( - anari::math::float3 center, float dist, anari::math::float2 azel) -{ - m_at = center; - m_distance = dist; - m_azel = azel; - m_speed = dist; - if (m_fixedDistance == tsd::math::inf) - m_fixedDistance = dist; - update(); -} - -inline void Manipulator::setCenter(anari::math::float3 center) -{ - setConfig(center, m_distance, m_azel); -} - -inline void Manipulator::setDistance(float dist) -{ - setConfig(m_at, dist, m_azel); -} - -inline void Manipulator::setFixedDistance(float dist) -{ - m_fixedDistance = dist; -} - -inline void Manipulator::setAzel(anari::math::float2 azel) -{ - setConfig(m_at, m_distance, azel); -} - -inline void Manipulator::setZoomSpeed(float speed) -{ - m_speed = speed; -} - -inline float Manipulator::zoomSpeed() const -{ - return m_speed; -} - -inline void Manipulator::startNewRotation() -{ - m_invertRotation = m_azel.y > 90.f && m_azel.y < 270.f; -} - -inline bool Manipulator::hasChanged(UpdateToken &t) const -{ - return tsd::core::versionChanged(t, m_token); -} - -inline void Manipulator::rotate(anari::math::float2 delta) -{ - delta *= 100; - if (m_axis == UpAxis::POS_Z || m_axis == UpAxis::NEG_X - || m_axis == UpAxis::NEG_Y) - delta.x = -delta.x; - delta.x = m_invertRotation ? -delta.x : delta.x; - delta.y = m_distance < 0.f ? -delta.y : delta.y; - m_azel += delta; - - auto maintainUnitCircle = [](float inDegrees) -> float { - while (inDegrees > 360.f) - inDegrees -= 360.f; - while (inDegrees < 0.f) - inDegrees += 360.f; - return inDegrees; - }; - - m_azel.x = maintainUnitCircle(m_azel.x); - m_azel.y = maintainUnitCircle(m_azel.y); - update(); -} - -inline void Manipulator::zoom(float delta) -{ - m_distance -= m_speed * delta; - update(); -} - -inline void Manipulator::pan(anari::math::float2 delta) -{ - delta *= m_speed; - - const anari::math::float3 amount = -delta.x * m_right + delta.y * m_up; - - m_eye += amount; - m_at += amount; - - update(); -} - -inline void Manipulator::setAxis(UpAxis axis) -{ - m_axis = axis; - update(); -} - -inline UpAxis Manipulator::axis() const -{ - return m_axis; -} - -inline anari::math::float2 Manipulator::azel() const -{ - return m_azel; -} - -inline anari::math::float3 Manipulator::eye() const -{ - return m_eye; -} - -inline anari::math::float3 Manipulator::at() const -{ - return m_at; -} - -inline anari::math::float3 Manipulator::dir() const -{ - return linalg::normalize(at() - eye()); -} - -inline anari::math::float3 Manipulator::up() const -{ - return m_up; -} - -inline float Manipulator::distance() const -{ - return m_distance; -} - -inline float Manipulator::fixedDistance() const -{ - return m_fixedDistance; -} - -inline anari::math::float3 Manipulator::eye_FixedDistance() const -{ - return m_eyeFixedDistance; -} - -inline void Manipulator::update() -{ - const float distance = std::abs(m_distance); - - const UpAxis axis = m_distance < 0.f ? negateAxis(m_axis) : m_axis; - - const float azimuth = tsd::math::radians(-m_azel.x); - const float elevation = tsd::math::radians(-m_azel.y); - - const anari::math::float3 toLocalManipulator = - azelToDirection(azimuth, elevation, axis); - - const anari::math::float3 localManipulatorPos = toLocalManipulator * distance; - const anari::math::float3 fromLocalManipulator = -localManipulatorPos; - - const anari::math::float3 alteredElevation = - azelToDirection(azimuth, elevation + 3, m_axis); - - const anari::math::float3 cameraRight = - linalg::cross(toLocalManipulator, alteredElevation); - const anari::math::float3 cameraUp = - linalg::cross(cameraRight, fromLocalManipulator); - - m_eye = localManipulatorPos + m_at; - m_up = linalg::normalize(cameraUp); - m_right = linalg::normalize(cameraRight); - - m_eyeFixedDistance = (toLocalManipulator * m_fixedDistance) + m_at; - - m_token++; -} - -inline UpAxis Manipulator::negateAxis(UpAxis current) const -{ - switch (current) { - case UpAxis::POS_X: - return UpAxis::NEG_X; - case UpAxis::POS_Y: - return UpAxis::NEG_Y; - case UpAxis::POS_Z: - return UpAxis::NEG_Z; - case UpAxis::NEG_X: - return UpAxis::POS_X; - case UpAxis::NEG_Y: - return UpAxis::POS_Y; - case UpAxis::NEG_Z: - return UpAxis::POS_Z; - } - return {}; -} - -inline anari::math::float3 Manipulator::azelToDirection( - float az, float el, UpAxis axis) const -{ - const float x = std::sin(az) * std::cos(el); - const float y = std::cos(az) * std::cos(el); - const float z = std::sin(el); - switch (axis) { - case UpAxis::POS_X: - return -normalize(anari::math::float3(z, y, x)); - case UpAxis::POS_Y: - return -normalize(anari::math::float3(x, z, y)); - case UpAxis::POS_Z: - return -normalize(anari::math::float3(x, y, z)); - case UpAxis::NEG_X: - return normalize(anari::math::float3(z, y, x)); - case UpAxis::NEG_Y: - return normalize(anari::math::float3(x, z, y)); - case UpAxis::NEG_Z: - return normalize(anari::math::float3(x, y, z)); - } - return {}; -} - } // namespace tsd::rendering diff --git a/tsd/src/tsd/rendering/view/ManipulatorToTSD.cpp b/tsd/src/tsd/rendering/view/ManipulatorToTSD.cpp index c98d4c557..20d210912 100644 --- a/tsd/src/tsd/rendering/view/ManipulatorToTSD.cpp +++ b/tsd/src/tsd/rendering/view/ManipulatorToTSD.cpp @@ -26,6 +26,7 @@ void updateCameraObject( c.setMetadataValue("manipulator.fixedDistance", m.fixedDistance()); c.setMetadataValue("manipulator.azel", m.azel()); c.setMetadataValue("manipulator.up", int(m.axis())); + c.setMetadataValue("manipulator.mode", int(m.mode())); } c.endParameterBatch(); @@ -37,11 +38,17 @@ void updateManipulatorFromCamera(Manipulator &m, const tsd::scene::Camera &c) auto d = c.getMetadataValue("manipulator.distance").getValueOr(m.distance()); auto azel = c.getMetadataValue("manipulator.azel").getValueOr(m.azel()); auto up = c.getMetadataValue("manipulator.up").getValueOr(int(m.axis())); + auto mode = c.getMetadataValue("manipulator.mode") + .getValueOr(int(ManipulatorMode::Orbit)); auto fd = c.getMetadataValue("manipulator.fixedDistance") .getValueOr(m.fixedDistance()); - m.setConfig(at, d, azel); - m.setAxis(static_cast(up)); + CameraPose pose; + pose.lookat = at; + pose.azeldist = {azel.x, azel.y, d}; + pose.upAxis = up; + pose.mode = mode; + m.setConfig(pose); m.setFixedDistance(fd); } diff --git a/tsd/src/tsd/scene/LayerNodeData.cpp b/tsd/src/tsd/scene/LayerNodeData.cpp index cfdb6f69b..b4d3a4383 100644 --- a/tsd/src/tsd/scene/LayerNodeData.cpp +++ b/tsd/src/tsd/scene/LayerNodeData.cpp @@ -181,7 +181,7 @@ void LayerNodeData::setAsTransform(const math::mat4 &m) auto &tl = m_srt[2]; math::mat4 rot; math::decomposeMatrix(m, sc, rot, tl); - azelrot = math::degrees(math::matrixToAzElRoll(rot)); + azelrot = math::normalizeDegrees(math::degrees(math::matrixToAzElRoll(rot))); } void LayerNodeData::setAsTransform( diff --git a/tsd/src/tsd/scene/Scene.hpp b/tsd/src/tsd/scene/Scene.hpp index b36231fdf..32120f98e 100644 --- a/tsd/src/tsd/scene/Scene.hpp +++ b/tsd/src/tsd/scene/Scene.hpp @@ -30,9 +30,14 @@ struct AnimationManager; } // namespace tsd::animation namespace tsd::io { +struct PayloadValidationResult; // clang-format off void save_Scene(scene::Scene &, core::DataNode &, bool, animation::AnimationManager *); void load_Scene(scene::Scene &, core::DataNode &, animation::AnimationManager *); +bool tryLoad_Scene(scene::Scene &, core::DataNode &, PayloadValidationResult *, animation::AnimationManager *); +void save_SceneCamerasAndRenderers(scene::Scene &, core::DataNode &); +void load_SceneCamerasAndRenderers(scene::Scene &, core::DataNode &); +bool tryLoad_SceneCamerasAndRenderers(scene::Scene &, core::DataNode &, PayloadValidationResult *); // clang-format on } // namespace tsd::io @@ -234,6 +239,17 @@ struct Scene Scene &, core::DataNode &, bool, tsd::animation::AnimationManager *); friend void ::tsd::io::load_Scene( Scene &, core::DataNode &, tsd::animation::AnimationManager *); + friend bool ::tsd::io::tryLoad_Scene( + Scene &, + core::DataNode &, + ::tsd::io::PayloadValidationResult *, + tsd::animation::AnimationManager *); + friend void ::tsd::io::save_SceneCamerasAndRenderers( + Scene &, core::DataNode &); + friend void ::tsd::io::load_SceneCamerasAndRenderers( + Scene &, core::DataNode &); + friend bool ::tsd::io::tryLoad_SceneCamerasAndRenderers( + Scene &, core::DataNode &, ::tsd::io::PayloadValidationResult *); template ObjectPoolRef createObjectImpl(ObjectPool &iv, Args &&...args); diff --git a/tsd/src/tsd/ui/imgui/Application.cpp b/tsd/src/tsd/ui/imgui/Application.cpp index 8f36a609f..6bffa32d4 100644 --- a/tsd/src/tsd/ui/imgui/Application.cpp +++ b/tsd/src/tsd/ui/imgui/Application.cpp @@ -12,6 +12,7 @@ #include "tsd/ui/imgui/Application.h" #include "tsd/ui/imgui/ArrayPreview.h" #include "tsd/ui/imgui/tsd_font.h" +#include "tsd/ui/imgui/tsd_ui_imgui.h" #include "tsd/ui/imgui/windows/Window.h" // SDL #include @@ -98,7 +99,15 @@ CommandLineOptions *Application::commandLineOptions() return &m_commandLine; } -void Application::getFilenameFromDialog(std::string &filenameOut, bool save) +void Application::getFilenameFromDialog( + std::string &filenameOut, bool isSaveDialog) +{ + getFilenameFromDialog(filenameOut, + isSaveDialog ? FileDialogMode::SaveFile : FileDialogMode::OpenFile); +} + +void Application::getFilenameFromDialog( + std::string &filenameOut, FileDialogMode mode) { auto fileDialogCb = [](void *userdata, const char *const *filelist, int filter) { @@ -112,9 +121,12 @@ void Application::getFilenameFromDialog(std::string &filenameOut, bool save) out = *filelist; }; - if (save) { + if (mode == FileDialogMode::SaveFile) { SDL_ShowSaveFileDialog( fileDialogCb, &filenameOut, this->sdlWindow(), nullptr, 0, nullptr); + } else if (mode == FileDialogMode::OpenDirectory) { + SDL_ShowOpenFolderDialog( + fileDialogCb, &filenameOut, this->sdlWindow(), nullptr, false); } else { SDL_ShowOpenFileDialog(fileDialogCb, &filenameOut, @@ -126,6 +138,30 @@ void Application::getFilenameFromDialog(std::string &filenameOut, bool save) } } +void Application::getFilenamesFromDialog(std::vector &filenamesOut) +{ + auto fileDialogCb = + [](void *userdata, const char *const *filelist, int filter) { + auto &out = *(std::vector *)userdata; + out.clear(); + if (!filelist) { + tsd::core::logError("SDL DIALOG ERROR: %s\n", SDL_GetError()); + return; + } + + for (auto file = filelist; *file; ++file) + out.emplace_back(*file); + }; + + SDL_ShowOpenFileDialog(fileDialogCb, + &filenamesOut, + this->sdlWindow(), + nullptr, + 0, + nullptr, + true); +} + void Application::showImportFileDialog() { m_fileDialog->show(); @@ -136,6 +172,19 @@ void Application::showExportNanoVDBFileDialog() m_exportNanoVDBFileDialog->show(); } +void Application::showImportObjectFileDialog( + TSDObjectFileType fileType, tsd::scene::LayerNodeRef importRoot) +{ + m_objectFileDialog->showImport(fileType, importRoot); +} + +void Application::showExportObjectFileDialog(TSDObjectFileType fileType, + anari::DataType objectType, + size_t objectIndex) +{ + m_objectFileDialog->showExport(fileType, objectType, objectIndex); +} + void Application::saveDefaultApplicationSettings() { saveGlobalApplicationSettings(); @@ -184,7 +233,7 @@ void Application::setupImGuiStyle() style.Alpha = 1.0f; style.DisabledAlpha = 0.6f; - style.WindowPadding = ImVec2(8.0f, 8.0f); + style.WindowPadding = ImVec2(12.0f, 12.0f); style.WindowRounding = 4.0f; style.WindowBorderSize = 1.0f; style.WindowMinSize = ImVec2(32.0f, 32.0f); @@ -337,6 +386,7 @@ WindowArray Application::setupWindows() m_offlineRenderModal = std::make_unique(this); m_fileDialog = std::make_unique(this); m_exportNanoVDBFileDialog = std::make_unique(this); + m_objectFileDialog = std::make_unique(this); m_vorticityDialog = std::make_unique(this); m_cuttingPlaneDialog = std::make_unique(this); @@ -423,6 +473,11 @@ void Application::uiFrameStart() modalActive = true; } + if (m_objectFileDialog->visible()) { + m_objectFileDialog->renderUI(); + modalActive = true; + } + if (m_vorticityDialog->visible()) { m_vorticityDialog->renderUI(); modalActive = true; @@ -480,28 +535,24 @@ void Application::uiMainMenuBar_File() if (ImGui::MenuItem("Load")) this->getFilenameFromDialog(m_filenameToLoadNextFrame); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Load session from a .tsd file"); + tooltipForPreviousItem("Load session from a .tsd file"); ImGui::Separator(); if (ImGui::MenuItem("Save", "CTRL+S")) doSave(); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Save session to a .tsd file"); + tooltipForPreviousItem("Save session to a .tsd file"); if (ImGui::MenuItem("Save As...", "CTRL+SHIFT+S")) this->getFilenameFromDialog(m_filenameToSaveNextFrame, true); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Save session to a chosen file name"); + tooltipForPreviousItem("Save session to a chosen file name"); if (ImGui::MenuItem("Quick Save", "CTRL+ALT+S")) doSave("state.tsd"); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Save sesson to 'state.tsd' in the local directory"); + tooltipForPreviousItem("Save sesson to 'state.tsd' in the local directory"); ImGui::Separator(); @@ -673,8 +724,7 @@ void Application::uiActionMenu(const std::vector &entries) "Executing Action..."); } - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("%s", entry.name.c_str()); + tooltipForPreviousItem(entry.name.c_str()); } } } @@ -699,6 +749,7 @@ void Application::saveApplicationState(const char *_filename) auto &ctx = *appContext(); auto &root = m_settings.root(); root.reset(); + tsd::core::writeDataTreeMetadata(root, applicationStateMetadata()); // Window state auto &windows = root["windows"]; @@ -746,6 +797,22 @@ void Application::saveApplicationState(const char *_filename) showTaskModal(doSave, "Please Wait: Saving Session..."); } +tsd::core::DataTreeMetadata Application::applicationStateMetadata() const +{ + return {tsd::core::DATA_TREE_METADATA_ENVELOPE_VERSION, + "application-state", + "tsd.ui.imgui.application-state", + 1}; +} + +bool Application::validateApplicationStateMetadata( + const tsd::core::DataTreeMetadataReadResult &, + const tsd::core::DataNode &, + const char *) const +{ + return true; +} + void Application::loadApplicationState(const char *filename) { // Load from file @@ -756,6 +823,11 @@ void Application::loadApplicationState(const char *filename) auto &ctx = *appContext(); auto &root = m_settings.root(); + if (!validateApplicationStateMetadata( + tsd::core::readDataTreeMetadata(root), root, filename)) { + root.reset(); + return; + } // TSD context from app state file, or context-only file if (auto *c = root.child("context"); c != nullptr) diff --git a/tsd/src/tsd/ui/imgui/Application.h b/tsd/src/tsd/ui/imgui/Application.h index eb3897980..e7e756d37 100644 --- a/tsd/src/tsd/ui/imgui/Application.h +++ b/tsd/src/tsd/ui/imgui/Application.h @@ -10,16 +10,19 @@ #include "modals/CuttingPlaneDialog.h" #include "modals/ExportNanoVDBFileDialog.h" #include "modals/ImportFileDialog.h" +#include "modals/ObjectFileDialog.h" #include "modals/OfflineRenderModal.h" #include "modals/VorticityDialog.h" // tsd_app #include "tsd/app/Context.h" // tsd_core +#include "tsd/core/DataTreeMetadata.hpp" #include "tsd/core/Logging.hpp" #include "tsd/core/TaskQueue.hpp" // SDL #include // std +#include #include #include #include @@ -43,6 +46,13 @@ struct CommandLineOptions std::string secondaryViewportLibrary; }; +enum class FileDialogMode +{ + OpenFile, + SaveFile, + OpenDirectory +}; + class Application { public: @@ -60,7 +70,10 @@ class Application CommandLineOptions *commandLineOptions(); void getFilenameFromDialog( - std::string &filenameOut, bool isSaveDialog = false); + std::string &filenameOut, + FileDialogMode mode = FileDialogMode::OpenFile); + void getFilenameFromDialog(std::string &filenameOut, bool isSaveDialog); + void getFilenamesFromDialog(std::vector &filenamesOut); // Enqueue a task to be executed on a background thread template @@ -69,8 +82,17 @@ class Application // Enqueue a task, then show a modal until task is complete template void showTaskModal(FUNCTION &&f, const char *text = "Please Wait"); + + // Enqueue a cancellable task, then show a modal until task is complete + template + void showTaskModalWithCancel(FUNCTION &&f, const char *text = "Please Wait"); void showImportFileDialog(); void showExportNanoVDBFileDialog(); + void showImportObjectFileDialog( + TSDObjectFileType fileType, tsd::scene::LayerNodeRef importRoot); + void showExportObjectFileDialog(TSDObjectFileType fileType, + anari::DataType objectType, + size_t objectIndex); void saveDefaultApplicationSettings(); ExtensionManager *extensionManager() const; @@ -112,6 +134,11 @@ class Application void saveApplicationState(const char *filename = "state.tsd"); void loadApplicationState(const char *filename = "state.tsd"); + virtual tsd::core::DataTreeMetadata applicationStateMetadata() const; + virtual bool validateApplicationStateMetadata( + const tsd::core::DataTreeMetadataReadResult &metadata, + const tsd::core::DataNode &root, + const char *filename) const; void saveApplicationSettings(tsd::core::DataNode &root); void loadApplicationSettings(tsd::core::DataNode &root); void saveGlobalApplicationSettings(); @@ -141,6 +168,7 @@ class Application std::unique_ptr m_offlineRenderModal; std::unique_ptr m_fileDialog; std::unique_ptr m_exportNanoVDBFileDialog; + std::unique_ptr m_objectFileDialog; std::unique_ptr m_vorticityDialog; std::unique_ptr m_cuttingPlaneDialog; @@ -208,4 +236,23 @@ inline void Application::showTaskModal(F &&f, const char *text) } } +template +inline void Application::showTaskModalWithCancel(F &&f, const char *text) +{ + auto cancelRequested = std::make_shared(false); + auto future = enqueueTask( + [task = std::forward(f), cancelRequested]() mutable { + task(*cancelRequested); + }); + + if (!m_taskModal) { + tsd::core::logWarning( + "[Application] No task modal available to show, " + "executing task without showing modal."); + future.wait(); + } else { + m_taskModal->activate(std::move(future), text, cancelRequested); + } +} + } // namespace tsd::ui::imgui diff --git a/tsd/src/tsd/ui/imgui/CMakeLists.txt b/tsd/src/tsd/ui/imgui/CMakeLists.txt index ba03d64f4..ce9f9ae03 100644 --- a/tsd/src/tsd/ui/imgui/CMakeLists.txt +++ b/tsd/src/tsd/ui/imgui/CMakeLists.txt @@ -9,6 +9,7 @@ project_sources(PRIVATE modals/AppSettingsDialog.cpp modals/BlockingTaskModal.cpp modals/ExportNanoVDBFileDialog.cpp + modals/ObjectFileDialog.cpp modals/OfflineRenderModal.cpp modals/ImportFileDialog.cpp modals/Modal.cpp diff --git a/tsd/src/tsd/ui/imgui/modals/AppSettingsDialog.cpp b/tsd/src/tsd/ui/imgui/modals/AppSettingsDialog.cpp index fbbde5ff6..6f07ca058 100644 --- a/tsd/src/tsd/ui/imgui/modals/AppSettingsDialog.cpp +++ b/tsd/src/tsd/ui/imgui/modals/AppSettingsDialog.cpp @@ -68,13 +68,11 @@ void AppSettingsDialog::buildUI_applicationSettings() if (ImGui::RadioButton( "all layers", kind == tsd::app::RenderIndexKind::ALL_LAYERS)) ctx->anari.setRenderIndexKind(tsd::app::RenderIndexKind::ALL_LAYERS); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Full render index with instancing support."); + tooltipForPreviousItem("Full render index with instancing support."); ImGui::SameLine(); if (ImGui::RadioButton("flat", kind == tsd::app::RenderIndexKind::FLAT)) ctx->anari.setRenderIndexKind(tsd::app::RenderIndexKind::FLAT); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Bypass instancing of objects."); + tooltipForPreviousItem("Bypass instancing of objects."); if (doUpdate) applySettings(); @@ -144,8 +142,7 @@ void AppSettingsDialog::buildUI_offlineRenderSettings() 1, std::numeric_limits::max()); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Number of total frames for [0.0-1.0] animation time."); + tooltipForPreviousItem("Number of total frames for [0.0-1.0] animation time."); ImGui::DragInt("frameIncrement", (int *)&ctx->offline.frame.frameIncrement, @@ -153,8 +150,7 @@ void AppSettingsDialog::buildUI_offlineRenderSettings() 1, std::max(1, ctx->offline.frame.numFrames / 2)); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Render every {N} frames"); + tooltipForPreviousItem("Render every {N} frames"); ImGui::Checkbox("render subset", &ctx->offline.frame.renderSubset); @@ -166,8 +162,7 @@ void AppSettingsDialog::buildUI_offlineRenderSettings() 0, ctx->offline.frame.numFrames - 1); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Offset into total frame count (when rendering subset)"); + tooltipForPreviousItem("Offset into total frame count (when rendering subset)"); doFix |= ImGui::DragInt("end frame offset", (int *)&ctx->offline.frame.endFrame, @@ -175,11 +170,9 @@ void AppSettingsDialog::buildUI_offlineRenderSettings() ctx->offline.frame.startFrame, ctx->offline.frame.numFrames - 1); - if (ImGui::IsItemHovered()) { - ImGui::SetTooltip( - "Stop at this frame (when rendering subset)," - " -1 means go to last frame in full animation"); - } + tooltipForPreviousItem( + "Stop at this frame (when rendering subset)," + " -1 means go to last frame in full animation"); ImGui::EndDisabled(); diff --git a/tsd/src/tsd/ui/imgui/modals/BlockingTaskModal.cpp b/tsd/src/tsd/ui/imgui/modals/BlockingTaskModal.cpp index a5fab10f3..0e6b5796d 100644 --- a/tsd/src/tsd/ui/imgui/modals/BlockingTaskModal.cpp +++ b/tsd/src/tsd/ui/imgui/modals/BlockingTaskModal.cpp @@ -36,15 +36,36 @@ void BlockingTaskModal::buildUI() m_timer.end(); ImGui::NewLine(); ImGui::TextDisabled("elapsed time: %.2fs", m_timer.seconds()); + + if (t->cancelRequested) { + ImGui::Separator(); + const bool cancelRequested = t->cancelRequested->load(); + ImGui::BeginDisabled(cancelRequested); + if (ImGui::Button("Cancel")) + t->cancelRequested->store(true); + ImGui::EndDisabled(); + } } void BlockingTaskModal::activate(tsd::core::Future &&f, const char *text) +{ + activate(std::move(f), text, {}); +} + +void BlockingTaskModal::activate(tsd::core::Future &&f, + const char *text, + std::shared_ptr cancelRequested) { std::lock_guard lock(m_mutex); if (m_tasks.empty()) m_timer.start(); - m_tasks.push_back({std::move(f), text}); + m_tasks.push_back({std::move(f), text, std::move(cancelRequested)}); this->show(); } +bool BlockingTaskModal::userClosable() const +{ + return false; +} + } // namespace tsd::ui::imgui diff --git a/tsd/src/tsd/ui/imgui/modals/BlockingTaskModal.h b/tsd/src/tsd/ui/imgui/modals/BlockingTaskModal.h index bbe1d6973..7c2e78e12 100644 --- a/tsd/src/tsd/ui/imgui/modals/BlockingTaskModal.h +++ b/tsd/src/tsd/ui/imgui/modals/BlockingTaskModal.h @@ -8,9 +8,11 @@ #include "tsd/core/TaskQueue.hpp" #include "tsd/core/Timer.hpp" // std +#include #include -#include +#include #include +#include namespace tsd::ui::imgui { @@ -22,12 +24,18 @@ struct BlockingTaskModal : public Modal void buildUI() override; void activate(tsd::core::Future &&f, const char *text = "Please Wait"); + void activate(tsd::core::Future &&f, + const char *text, + std::shared_ptr cancelRequested); private: + bool userClosable() const override; + struct RunningTask { tsd::core::Future future; std::string text; + std::shared_ptr cancelRequested; }; std::deque m_tasks; diff --git a/tsd/src/tsd/ui/imgui/modals/Modal.cpp b/tsd/src/tsd/ui/imgui/modals/Modal.cpp index 90c8bb4f4..8c87e033b 100644 --- a/tsd/src/tsd/ui/imgui/modals/Modal.cpp +++ b/tsd/src/tsd/ui/imgui/modals/Modal.cpp @@ -22,8 +22,9 @@ void Modal::renderUI() ImVec2(0.5f, 0.5f)); ImGui::OpenPopup(m_name.c_str()); + bool *visible = userClosable() ? &m_visible : nullptr; if (ImGui::BeginPopupModal( - m_name.c_str(), &m_visible, ImGuiWindowFlags_AlwaysAutoResize)) { + m_name.c_str(), visible, ImGuiWindowFlags_AlwaysAutoResize)) { buildUI(); ImGui::EndPopup(); } @@ -49,6 +50,11 @@ const char *Modal::name() const return m_name.c_str(); } +bool Modal::userClosable() const +{ + return true; +} + tsd::app::Context *Modal::appContext() const { return m_app ? m_app->appContext() : nullptr; diff --git a/tsd/src/tsd/ui/imgui/modals/Modal.h b/tsd/src/tsd/ui/imgui/modals/Modal.h index 644ad14e3..08aabba3b 100644 --- a/tsd/src/tsd/ui/imgui/modals/Modal.h +++ b/tsd/src/tsd/ui/imgui/modals/Modal.h @@ -28,6 +28,7 @@ struct Modal protected: virtual void buildUI() = 0; + virtual bool userClosable() const; tsd::app::Context *appContext() const; Application *m_app{nullptr}; diff --git a/tsd/src/tsd/ui/imgui/modals/ObjectFileDialog.cpp b/tsd/src/tsd/ui/imgui/modals/ObjectFileDialog.cpp new file mode 100644 index 000000000..0a0586848 --- /dev/null +++ b/tsd/src/tsd/ui/imgui/modals/ObjectFileDialog.cpp @@ -0,0 +1,191 @@ +// Copyright 2024-2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "ObjectFileDialog.h" +// tsd_core +#include "tsd/core/Logging.hpp" +// tsd_io +#include "tsd/io/serialization.hpp" +// tsd_ui_imgui +#include "tsd/ui/imgui/Application.h" +// imgui +#include + +namespace tsd::ui::imgui { + +ObjectFileDialog::ObjectFileDialog(Application *app) + : Modal(app, "TSD Object File") +{} + +ObjectFileDialog::~ObjectFileDialog() = default; + +void ObjectFileDialog::showImport( + TSDObjectFileType fileType, tsd::scene::LayerNodeRef importRoot) +{ + m_mode = Mode::Import; + m_fileType = fileType; + m_importRoot = importRoot; + m_exportObjectType = ANARI_UNKNOWN; + m_exportObjectIndex = tsd::core::INVALID_INDEX; + m_filename.clear(); + m_dialogFilename.clear(); + show(); +} + +void ObjectFileDialog::showExport(TSDObjectFileType fileType, + anari::DataType objectType, + size_t objectIndex) +{ + m_mode = Mode::Export; + m_fileType = fileType; + m_importRoot = {}; + m_exportObjectType = objectType; + m_exportObjectIndex = objectIndex; + m_filename.clear(); + m_dialogFilename.clear(); + show(); +} + +void ObjectFileDialog::buildUI() +{ + ImGui::Text("%s %s", actionLabel(), fileTypeLabel()); + + if (ImGui::Button("...")) { + m_dialogFilename.clear(); + m_app->getFilenameFromDialog(m_dialogFilename, m_mode == Mode::Export); + } + + if (!m_dialogFilename.empty()) { + m_filename = m_dialogFilename; + m_dialogFilename.clear(); + } + + ImGui::SameLine(); + ImGui::SetNextItemWidth(800.f); + ImGui::InputText("##filename", &m_filename); + + ImGui::NewLine(); + + if (ImGui::Button("cancel") || ImGui::IsKeyDown(ImGuiKey_Escape)) { + hide(); + return; + } + + ImGui::SameLine(); + + ImGui::BeginDisabled(m_filename.empty()); + if (ImGui::Button(m_mode == Mode::Import ? "import" : "export")) { + hide(); + if (m_mode == Mode::Import) + importFile(); + else + exportFile(); + } + ImGui::EndDisabled(); +} + +const char *ObjectFileDialog::fileTypeLabel() const +{ + switch (m_fileType) { + case TSDObjectFileType::Surface: + return "TSD Surface"; + case TSDObjectFileType::Volume: + return "TSD Volume"; + } + + return "TSD Object"; +} + +const char *ObjectFileDialog::actionLabel() const +{ + return m_mode == Mode::Import ? "Import" : "Export"; +} + +const char *ObjectFileDialog::taskLabel() const +{ + return m_mode == Mode::Import ? "Please Wait: Importing TSD Object..." + : "Please Wait: Exporting TSD Object..."; +} + +anari::DataType ObjectFileDialog::anariObjectType() const +{ + switch (m_fileType) { + case TSDObjectFileType::Surface: + return ANARI_SURFACE; + case TSDObjectFileType::Volume: + return ANARI_VOLUME; + } + + return ANARI_UNKNOWN; +} + +void ObjectFileDialog::importFile() +{ + auto filename = m_filename; + auto fileType = m_fileType; + auto importRoot = m_importRoot; + auto *app = m_app; + + auto doImport = [filename, fileType, importRoot, app]() mutable { + auto *ctx = app->appContext(); + auto &scene = ctx->tsd.scene; + + if (!importRoot.valid()) + importRoot = scene.defaultLayer()->root(); + + tsd::scene::Object *importedObject = nullptr; + if (fileType == TSDObjectFileType::Surface) { + auto importedSurface = tsd::io::import_Surface(scene, filename.c_str()); + importedObject = importedSurface.data(); + } else { + auto importedVolume = tsd::io::import_Volume(scene, filename.c_str()); + importedObject = importedVolume.data(); + } + + if (!importedObject) + return; + + const auto nodeName = importedObject->name().empty() + ? std::string(fileType == TSDObjectFileType::Surface ? "surface" + : "volume") + : importedObject->name(); + scene.insertChildObjectNode(importRoot, + importedObject->type(), + importedObject->index(), + nodeName.c_str()); + }; + + m_app->showTaskModal(doImport, taskLabel()); +} + +void ObjectFileDialog::exportFile() +{ + auto filename = m_filename; + auto expectedType = anariObjectType(); + auto objectType = m_exportObjectType; + auto objectIndex = m_exportObjectIndex; + auto *app = m_app; + + auto doExport = [filename, expectedType, objectType, objectIndex, app]() { + auto *ctx = app->appContext(); + auto &scene = ctx->tsd.scene; + auto *object = scene.getObject(objectType, objectIndex); + + if (!object) { + tsd::core::logError("[ObjectFileDialog] No object selected for export."); + return; + } + + if (object->type() != expectedType) { + tsd::core::logError("[ObjectFileDialog] Selected object is not a %s.", + anari::toString(expectedType)); + return; + } + + tsd::io::export_Object(filename.c_str(), *object); + }; + + m_app->showTaskModal(doExport, taskLabel()); +} + +} // namespace tsd::ui::imgui diff --git a/tsd/src/tsd/ui/imgui/modals/ObjectFileDialog.h b/tsd/src/tsd/ui/imgui/modals/ObjectFileDialog.h new file mode 100644 index 000000000..60490b465 --- /dev/null +++ b/tsd/src/tsd/ui/imgui/modals/ObjectFileDialog.h @@ -0,0 +1,56 @@ +// Copyright 2024-2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "Modal.h" +// std +#include +#include + +namespace tsd::ui::imgui { + +enum class TSDObjectFileType +{ + Surface, + Volume +}; + +struct ObjectFileDialog : public Modal +{ + ObjectFileDialog(Application *app); + ~ObjectFileDialog() override; + + void showImport( + TSDObjectFileType fileType, tsd::scene::LayerNodeRef importRoot); + void showExport(TSDObjectFileType fileType, + anari::DataType objectType, + size_t objectIndex); + + void buildUI() override; + + private: + enum class Mode + { + Import, + Export + }; + + const char *fileTypeLabel() const; + const char *actionLabel() const; + const char *taskLabel() const; + anari::DataType anariObjectType() const; + + void importFile(); + void exportFile(); + + std::string m_filename; + std::string m_dialogFilename; + Mode m_mode{Mode::Import}; + TSDObjectFileType m_fileType{TSDObjectFileType::Surface}; + tsd::scene::LayerNodeRef m_importRoot; + anari::DataType m_exportObjectType{ANARI_UNKNOWN}; + size_t m_exportObjectIndex{tsd::core::INVALID_INDEX}; +}; + +} // namespace tsd::ui::imgui diff --git a/tsd/src/tsd/ui/imgui/tsd_ui_imgui.cpp b/tsd/src/tsd/ui/imgui/tsd_ui_imgui.cpp index 13c500e96..657ab1b45 100644 --- a/tsd/src/tsd/ui/imgui/tsd_ui_imgui.cpp +++ b/tsd/src/tsd/ui/imgui/tsd_ui_imgui.cpp @@ -359,11 +359,9 @@ void buildUI_object(tsd::scene::Object &o, o.useCount(tsd::scene::Object::UseKind::LAYER), o.useCount(tsd::scene::Object::UseKind::ANIM), o.useCount(tsd::scene::Object::UseKind::INTERNAL)); - if (ImGui::IsItemHovered()) { - ImGui::SetTooltip( - "references to this object:" - " application | parameter | layer | animation | internal"); - } + tooltipForPreviousItem( + "references to this object:" + " application | parameter | layer | animation | internal"); ImGui::Separator(); @@ -845,4 +843,14 @@ size_t buildUI_objects_menulist( return retval; } -} // namespace tsd::ui \ No newline at end of file +void tooltipForPreviousItem(const char *text, bool showWhenDisabled) +{ + ImGuiHoveredFlags flags = 0; + if (showWhenDisabled) + flags |= ImGuiHoveredFlags_AllowWhenDisabled; + + if (ImGui::IsItemHovered(flags)) + ImGui::SetTooltip("%s", text); +} + +} // namespace tsd::ui diff --git a/tsd/src/tsd/ui/imgui/tsd_ui_imgui.h b/tsd/src/tsd/ui/imgui/tsd_ui_imgui.h index b7e979bf0..3e79c4fc1 100644 --- a/tsd/src/tsd/ui/imgui/tsd_ui_imgui.h +++ b/tsd/src/tsd/ui/imgui/tsd_ui_imgui.h @@ -24,4 +24,6 @@ bool buildUI_parameter(tsd::scene::Object &o, size_t buildUI_objects_menulist( const tsd::scene::Scene &scene, anari::DataType &type); +void tooltipForPreviousItem(const char *text, bool showWhenDisabled = true); + } // namespace tsd::ui diff --git a/tsd/src/tsd/ui/imgui/windows/BaseViewport.cpp b/tsd/src/tsd/ui/imgui/windows/BaseViewport.cpp index 0199792d8..8efae037a 100644 --- a/tsd/src/tsd/ui/imgui/windows/BaseViewport.cpp +++ b/tsd/src/tsd/ui/imgui/windows/BaseViewport.cpp @@ -525,6 +525,11 @@ void BaseViewport::ui_menubar_Camera() if (ImGui::Combo("Up", &axis, "+x\0+y\0+z\0-x\0-y\0-z\0\0")) m_camera.arcball->setAxis(static_cast(axis)); + auto mode = static_cast(m_camera.arcball->mode()); + if (ImGui::Combo("Mode", &mode, "Orbit\0Look\0\0")) + m_camera.arcball->setMode( + static_cast(mode)); + auto at = m_camera.arcball->at(); auto azel = m_camera.arcball->azel(); auto dist = m_camera.arcball->distance(); @@ -537,8 +542,7 @@ void BaseViewport::ui_menubar_Camera() ImGui::BeginDisabled( m_camera.current->subtype() != scene::tokens::camera::orthographic); update |= ImGui::DragFloat("Near", &fixedDist); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("near plane distance for orthographic camera"); + tooltipForPreviousItem("near plane distance for orthographic camera"); ImGui::EndDisabled(); if (update) { @@ -653,6 +657,12 @@ int BaseViewport::windowFlags() const return ImGuiWindowFlags_MenuBar | ImGuiWindowFlags_NoScrollbar; } +int BaseViewport::pushStyle() +{ + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4.f, 4.f)); + return 1; +} + void BaseViewport::applyViewMatrixToArcball(const float *viewMat) { // Extract forward direction from column-major view matrix produced by @@ -714,8 +724,7 @@ void BaseViewport::applyViewMatrixToArcball(const float *viewMat) const tsd::math::float2 newAzel{ -tsd::math::degrees(az_rad), -tsd::math::degrees(el_rad)}; - m_camera.arcball->setConfig( - m_camera.arcball->at(), m_camera.arcball->distance(), newAzel); + m_camera.arcball->setAzel(newAzel); } } // namespace tsd::ui::imgui diff --git a/tsd/src/tsd/ui/imgui/windows/BaseViewport.h b/tsd/src/tsd/ui/imgui/windows/BaseViewport.h index 22257e54e..aad9b4e7f 100644 --- a/tsd/src/tsd/ui/imgui/windows/BaseViewport.h +++ b/tsd/src/tsd/ui/imgui/windows/BaseViewport.h @@ -98,6 +98,7 @@ struct BaseViewport : public Window private: int windowFlags() const override; + int pushStyle() override; void applyViewMatrixToArcball(const float *viewMat); tsd::rendering::ImagePipeline m_pipeline; diff --git a/tsd/src/tsd/ui/imgui/windows/CameraPoses.cpp b/tsd/src/tsd/ui/imgui/windows/CameraPoses.cpp index edc53aaf1..49d27a4b6 100644 --- a/tsd/src/tsd/ui/imgui/windows/CameraPoses.cpp +++ b/tsd/src/tsd/ui/imgui/windows/CameraPoses.cpp @@ -6,6 +6,7 @@ #include "tsd/app/Context.h" #include "tsd/app/renderAnimationSequence.h" // tsd_ui_imgui +#include "tsd/ui/imgui/tsd_ui_imgui.h" #include "tsd/ui/imgui/windows/Viewport.h" // tsd_core #include "tsd/core/Logging.hpp" @@ -38,32 +39,27 @@ void CameraPoses::buildUI() if (ImGui::Button("current view")) appContext()->addCurrentViewToCameraPoses(); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("insert new view using the current camera view"); + tooltipForPreviousItem("insert new view using the current camera view"); ImGui::SameLine(); if (ImGui::Button("turntable views")) ImGui::OpenPopup("CameraPoses_turntablePopupMenu"); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("add a series of turntable camera poses"); + tooltipForPreviousItem("add a series of turntable camera poses"); #if 0 ImGui::SameLine(); ImGui::BeginDisabled(!m_viewport); if (ImGui::Button("camera")) m_viewport->addCameraObjectFromCurrentView(); + tooltipForPreviousItem("add new camera object from current view"); ImGui::EndDisabled(); #endif - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("add new camera object from current view"); - if (ImGui::Button("clear")) ImGui::OpenPopup("CameraPoses_confirmPopupMenu"); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("clear all camera poses"); + tooltipForPreviousItem("clear all camera poses"); ImGui::Separator(); @@ -88,22 +84,19 @@ void CameraPoses::buildUI() ImGui::TableSetColumnIndex(1); if (ImGui::Button(">")) appContext()->setCameraPose(p); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("set as current view"); + tooltipForPreviousItem("set as current view"); ImGui::TableSetColumnIndex(2); if (ImGui::Button("+")) { appContext()->updateExistingCameraPoseFromView(p); tsd::core::logStatus("camera pose '%s' updated", p.name.c_str()); } - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("update this pose from current view"); + tooltipForPreviousItem("update this pose from current view"); ImGui::TableSetColumnIndex(3); if (ImGui::Button("-")) toRemove = i; - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("delete this pose"); + tooltipForPreviousItem("delete this pose"); ImGui::PopID(); i++; } @@ -125,20 +118,16 @@ void CameraPoses::buildUI_turntablePopupMenu() { if (ImGui::BeginPopup("CameraPoses_turntablePopupMenu")) { ImGui::InputFloat3("azimuths", &m_turntableAzimuths.x, "%.3f"); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("{min, max, step size}"); + tooltipForPreviousItem("{min, max, step size}"); ImGui::InputFloat3("elevations", &m_turntableElevations.x, "%.3f"); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("{min, max, step size}"); + tooltipForPreviousItem("{min, max, step size}"); ImGui::InputFloat3("center", &m_turntableCenter.x, "%.3f"); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("view center"); + tooltipForPreviousItem("view center"); ImGui::InputFloat("distance", &m_turntableDistance, 0.01f, 0.1f, "%.3f"); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("view distance from center"); + tooltipForPreviousItem("view distance from center"); if (ImGui::Button("ok")) { appContext()->addTurntableCameraPoses(m_turntableAzimuths, @@ -187,14 +176,15 @@ void CameraPoses::buildUI_interpolationControls() pathSettings.type = static_cast(currentType); } - if (ImGui::IsItemHovered() && !m_isRendering) { + if (!m_isRendering) { switch (pathSettings.type) { case tsd::rendering::CameraPathInterpolationType::LINEAR: - ImGui::SetTooltip("Linear interpolation (constant velocity)"); + tooltipForPreviousItem("Linear interpolation (constant velocity)", false); break; case tsd::rendering::CameraPathInterpolationType::SMOOTH: - ImGui::SetTooltip( - "Smooth motion through all poses with ease-in/out at start and end"); + tooltipForPreviousItem( + "Smooth motion through all poses with ease-in/out at start and end", + false); break; } } @@ -204,32 +194,28 @@ void CameraPoses::buildUI_interpolationControls() == tsd::rendering::CameraPathInterpolationType::SMOOTH) { ImGui::DragFloat( "smoothness", &pathSettings.smoothness, 0.01f, 0.0f, 1.0f, "%.2f"); - if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("Path smoothness (0=minimal, 1=maximum)"); - } + tooltipForPreviousItem("Path smoothness (0=minimal, 1=maximum)"); } ImGui::DragInt( "frames per pose", &pathSettings.framesPerSegment, 1.0f, 1, 1000); - if (ImGui::IsItemHovered() && !m_isRendering) { - if (hasPoses) { - ImGui::SetTooltip( - "Number of frames per pose (controls total frame count)"); - } else { - ImGui::SetTooltip("Add at least 2 poses to enable interpolation"); - } + if (!m_isRendering) { + if (hasPoses) + tooltipForPreviousItem( + "Number of frames per pose (controls total frame count)", false); + else + tooltipForPreviousItem( + "Add at least 2 poses to enable interpolation", false); } ImGui::DragInt("fps", &pathSettings.framesPerSecond, 1.0f, 1, 240); - if (ImGui::IsItemHovered() && !m_isRendering) { - ImGui::SetTooltip("Frames per second for the camera animation timeline"); - } + if (!m_isRendering) + tooltipForPreviousItem( + "Frames per second for the camera animation timeline", false); ImGui::Checkbox("update viewport", &m_updateViewport); - if (ImGui::IsItemHovered()) { - ImGui::SetTooltip( - "Update the main viewport camera while rendering (live preview)"); - } + tooltipForPreviousItem( + "Update the main viewport camera while rendering (live preview)"); ImGui::EndDisabled(); // End disable block for controls @@ -251,18 +237,18 @@ void CameraPoses::buildUI_interpolationControls() renderInterpolatedPath(); } ImGui::EndDisabled(); - if (ImGui::IsItemHovered() && hasPoses) { + if (hasPoses) { const int numPoses = static_cast(appContext()->view.poses.size()); const int totalFrames = numPoses * pathSettings.framesPerSegment + 1; const float durationSeconds = (totalFrames - 1) / static_cast(std::max(1, pathSettings.framesPerSecond)); - ImGui::SetTooltip( - "Render %d frames (%d poses × %d frames per pose, %.2fs @ %d fps)", - totalFrames, - numPoses, - pathSettings.framesPerSegment, - durationSeconds, - pathSettings.framesPerSecond); + std::ostringstream tooltip; + tooltip << "Render " << totalFrames << " frames (" << numPoses + << " poses × " << pathSettings.framesPerSegment + << " frames per pose, " << std::fixed << std::setprecision(2) + << durationSeconds << "s @ " << pathSettings.framesPerSecond + << " fps)"; + tooltipForPreviousItem(tooltip.str().c_str(), false); } } else { // Cancel button is always enabled during rendering @@ -270,9 +256,7 @@ void CameraPoses::buildUI_interpolationControls() m_cancelRequested = true; tsd::core::logStatus("[CameraPoses] Cancellation requested..."); } - if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("Cancel the current rendering"); - } + tooltipForPreviousItem("Cancel the current rendering"); } // Show rendering progress diff --git a/tsd/src/tsd/ui/imgui/windows/LayerTree.cpp b/tsd/src/tsd/ui/imgui/windows/LayerTree.cpp index 51745479c..5f1e8ee77 100644 --- a/tsd/src/tsd/ui/imgui/windows/LayerTree.cpp +++ b/tsd/src/tsd/ui/imgui/windows/LayerTree.cpp @@ -11,6 +11,8 @@ #include "tsd/ui/imgui/modals/ExportNanoVDBFileDialog.h" #include "tsd/ui/imgui/modals/ImportFileDialog.h" #include "tsd/ui/imgui/tsd_ui_imgui.h" +// std +#include namespace tsd::ui::imgui { @@ -73,7 +75,7 @@ void LayerTree::buildUI_layerHeader() layers.size()); if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("right-click to set layer visibility"); + tooltipForPreviousItem("right-click to set layer visibility", false); if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { ImGui::OpenPopup("LayerTree_contextMenu_setActiveLayers"); m_activeLayerMenuTriggered = true; @@ -280,11 +282,14 @@ void LayerTree::buildUI_tree() if (ImGui::IsItemHovered()) { m_hoveredNode = node.index(); if (node->isObject()) { - ImGui::SetTooltip("object: %s[%zu]", - anari::toString(node->type()), - node->getObjectIndex()); + std::string tooltip = "object: "; + tooltip += anari::toString(node->type()); + tooltip += '['; + tooltip += std::to_string(node->getObjectIndex()); + tooltip += ']'; + tooltipForPreviousItem(tooltip.c_str(), false); } else if (node->isTransform()) - ImGui::SetTooltip("transform: ANARI_FLOAT32_MAT4"); + tooltipForPreviousItem("transform: ANARI_FLOAT32_MAT4", false); } if (ImGui::IsItemClicked() && m_menuNode == TSD_INVALID_INDEX) { @@ -679,8 +684,8 @@ void LayerTree::buildUI_objectSceneMenu() tsd::scene::GeometryRef g; #define OBJECT_UI_MENU_ITEM(text, subtype) \ if (ImGui::MenuItem(text)) { \ - g = scene.createObject( \ - tsd::scene::tokens::geometry::subtype); \ + g = scene.createObject( \ + tsd::scene::tokens::geometry::subtype); \ } OBJECT_UI_MENU_ITEM("cone", cone); OBJECT_UI_MENU_ITEM("curve", curve); @@ -768,6 +773,37 @@ void LayerTree::buildUI_objectSceneMenu() ImGui::EndMenu(); } + ImGui::Separator(); + + if (ImGui::BeginMenu("import TSD")) { + if (ImGui::MenuItem("volume")) + m_app->showImportObjectFileDialog(TSDObjectFileType::Volume, menuNode); + + if (ImGui::MenuItem("surface")) + m_app->showImportObjectFileDialog(TSDObjectFileType::Surface, menuNode); + + ImGui::EndMenu(); + } + + auto *menuObject = nodeSelected && (*menuNode)->isObject() + ? (*menuNode)->getObject() + : nullptr; + bool canExport = menuObject + && (menuObject->type() == ANARI_VOLUME + || menuObject->type() == ANARI_SURFACE); + + if (canExport) { + ImGui::Separator(); + if (ImGui::MenuItem("export TSD")) { + auto exportType = menuObject->type() == ANARI_VOLUME + ? TSDObjectFileType::Volume + : TSDObjectFileType::Surface; + + m_app->showExportObjectFileDialog( + exportType, menuObject->type(), menuObject->index()); + } + } + if (nodeSelected) { if ((*menuNode)->isObject() && (*menuNode)->getObject()->subtype() diff --git a/tsd/src/tsd/ui/imgui/windows/MultiDeviceViewport.cpp b/tsd/src/tsd/ui/imgui/windows/MultiDeviceViewport.cpp index 1e0fe3ce5..30ac1a70f 100644 --- a/tsd/src/tsd/ui/imgui/windows/MultiDeviceViewport.cpp +++ b/tsd/src/tsd/ui/imgui/windows/MultiDeviceViewport.cpp @@ -71,8 +71,14 @@ void MultiDeviceViewport::resetView(bool resetAzEl) getSceneBounds(bounds); auto center = 0.5f * (bounds[0] + bounds[1]); auto diag = bounds[1] - bounds[0]; + const auto mode = m_arcball->mode(); auto azel = resetAzEl ? tsd::math::float2(0.f, 20.f) : m_arcball->azel(); - m_arcball->setConfig(center, 1.25f * linalg::length(diag), azel); + if (mode == tsd::rendering::ManipulatorMode::Look && !resetAzEl) { + m_arcball->setDistance(1.25f * linalg::length(diag)); + } else { + m_arcball->setConfig(center, 1.25f * linalg::length(diag), azel); + m_arcball->setMode(mode); + } m_cameraToken = 0; } @@ -190,18 +196,24 @@ void MultiDeviceViewport::loadSettings(tsd::core::DataNode &root) float distance = 0.f; tsd::math::float2 azel(0.f); int axis = 0; + int mode = 0; auto &camera = *c; camera["at"].getValue(ANARI_FLOAT32_VEC3, &at); camera["distance"].getValue(ANARI_FLOAT32, &distance); camera["azel"].getValue(ANARI_FLOAT32_VEC2, &azel); camera["up"].getValue(ANARI_INT32, &axis); + camera["mode"].getValue(ANARI_INT32, &mode); camera["apertureRadius"].getValue(ANARI_FLOAT32, &m_apertureRadius); camera["focusDistance"].getValue(ANARI_FLOAT32, &m_focusDistance); - m_arcball->setAxis(tsd::rendering::UpAxis(axis)); - m_arcball->setConfig(at, distance, azel); + tsd::rendering::CameraPose pose; + pose.lookat = at; + pose.azeldist = {azel.x, azel.y, distance}; + pose.upAxis = axis; + pose.mode = mode; + m_arcball->setConfig(pose); } } @@ -357,6 +369,10 @@ void MultiDeviceViewport::ui_menubar() resetView(); } + auto mode = static_cast(m_arcball->mode()); + if (ImGui::Combo("mode", &mode, "Orbit\0Look\0\0")) + m_arcball->setMode(static_cast(mode)); + ImGui::Separator(); ImGui::Text("Perspective Parameters:"); @@ -511,6 +527,12 @@ int MultiDeviceViewport::windowFlags() const return ImGuiWindowFlags_MenuBar; } +int MultiDeviceViewport::pushStyle() +{ + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4.f, 4.f)); + return 1; +} + void MultiDeviceViewport::RendererUpdateDelegate::signalParameterUpdated( const tsd::scene::Object *o, const tsd::scene::Parameter *p) { diff --git a/tsd/src/tsd/ui/imgui/windows/MultiDeviceViewport.h b/tsd/src/tsd/ui/imgui/windows/MultiDeviceViewport.h index 83423b49a..e715e5e51 100644 --- a/tsd/src/tsd/ui/imgui/windows/MultiDeviceViewport.h +++ b/tsd/src/tsd/ui/imgui/windows/MultiDeviceViewport.h @@ -44,6 +44,7 @@ struct MultiDeviceViewport : public Window void ui_handleInput(); int windowFlags() const override; + int pushStyle() override; // ImGui input state // diff --git a/tsd/src/tsd/ui/imgui/windows/Timeline.cpp b/tsd/src/tsd/ui/imgui/windows/Timeline.cpp index 40e6bba1e..58a8ed623 100644 --- a/tsd/src/tsd/ui/imgui/windows/Timeline.cpp +++ b/tsd/src/tsd/ui/imgui/windows/Timeline.cpp @@ -14,6 +14,7 @@ #include #include #include +#include namespace tsd::ui::imgui { @@ -109,13 +110,11 @@ void Timeline::buildUI_transport() if (animMgr.isPlaying()) { if (ImGui::Button("||")) animMgr.stop(); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Pause (Space)"); + tooltipForPreviousItem("Pause (Space)"); } else { if (ImGui::Button(" > ")) animMgr.play(); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Play (Space)"); + tooltipForPreviousItem("Play (Space)"); } ImGui::SameLine(); @@ -126,8 +125,7 @@ void Timeline::buildUI_transport() animMgr.setAnimationFrame(0); } - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Stop"); + tooltipForPreviousItem("Stop"); ImGui::SameLine(); @@ -316,8 +314,7 @@ void Timeline::buildUI_canvas() } } - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("%s", anim.name().c_str()); + tooltipForPreviousItem(anim.name().c_str()); if (selected) ImGui::PopStyleColor(); @@ -375,7 +372,10 @@ void Timeline::buildUI_canvas() if (ImGui::IsItemHovered()) { int kframe = static_cast( std::round(b.timeBase()[ki] * (totalFrames - 1))); - ImGui::SetTooltip("%s @ frame %d", b.paramName().c_str(), kframe); + std::string tooltip = b.paramName().c_str(); + tooltip += " @ frame "; + tooltip += std::to_string(kframe); + tooltipForPreviousItem(tooltip.c_str(), false); } if (ImGui::BeginPopupContextItem("##bkf_ctx")) { if (ImGui::MenuItem("Delete keyframe")) @@ -415,7 +415,9 @@ void Timeline::buildUI_canvas() if (ImGui::IsItemHovered()) { int kframe = static_cast( std::round(tfb.timeBase()[ki] * (totalFrames - 1))); - ImGui::SetTooltip("Transform @ frame %d", kframe); + std::string tooltip = "Transform @ frame "; + tooltip += std::to_string(kframe); + tooltipForPreviousItem(tooltip.c_str(), false); } ImGui::PopID(); } diff --git a/tsd/src/tsd/ui/imgui/windows/TransferFunctionEditor.cpp b/tsd/src/tsd/ui/imgui/windows/TransferFunctionEditor.cpp index dcd1d8095..6b48affb8 100644 --- a/tsd/src/tsd/ui/imgui/windows/TransferFunctionEditor.cpp +++ b/tsd/src/tsd/ui/imgui/windows/TransferFunctionEditor.cpp @@ -8,7 +8,7 @@ // tsd_app #include "tsd/app/Context.h" // tsd_io -#include "tsd/io/importers/detail/importer_common.hpp" +#include "tsd/io/importers.hpp" // tsd_ui_imgui #include "tsd/ui/imgui/Application.h" #include "tsd/ui/imgui/tsd_ui_imgui.h" @@ -193,11 +193,11 @@ void TransferFunctionEditor::buildUI_drawEditor() m_tfnOpacityPoints[i + 1].x); } updateColormaps(); - } else if (ImGui::IsItemHovered()) { - ImGui::SetTooltip( + } else + tooltipForPreviousItem( "Double right click button to delete point\n" - "Left click and drag to move point"); - } + "Left click and drag to move point", + false); } } @@ -520,6 +520,25 @@ void TransferFunctionEditor::loadDefaultMaps() m_tfnsNames.push_back(name); }; + auto addColorPoints = + [&](const std::vector &colorPoints, + const std::string &name, + const std::string &source) { + auto existing = + std::find(m_tfnsNames.begin(), m_tfnsNames.end(), name); + if (existing != m_tfnsNames.end()) { + auto index = std::distance(m_tfnsNames.begin(), existing); + m_tfnsColorPoints[index] = colorPoints; + tsd::core::logStatus( + ("[tfn_editor] Replaced color map '" + name + "' from " + + source) + .c_str()); + } else { + m_tfnsColorPoints.push_back(colorPoints); + m_tfnsNames.push_back(name); + } + }; + addColorMap(tsd::core::colormap::jet, "Jet"); addColorMap(tsd::core::colormap::cool_to_warm, "Cool to Warm"); addColorMap(tsd::core::colormap::viridis, "Viridis"); @@ -527,16 +546,18 @@ void TransferFunctionEditor::loadDefaultMaps() addColorMap(tsd::core::colormap::inferno, "Inferno"); addColorMap(tsd::core::colormap::ice_fire, "Ice Fire"); addColorMap(tsd::core::colormap::grayscale, "Grayscale"); + + for (const auto &colorMap : tsd::io::loadUserColorMaps()) { + addColorPoints( + colorMap.colorPoints, colorMap.name, colorMap.path.string()); + } }; void TransferFunctionEditor::loadColormap( const std::string &filepath, const std::string &name) { - // Use the centralized import function - auto &scene = appContext()->tsd.scene; - // Extract control points from the loaded transfer function - core::TransferFunction tfn = tsd::io::importTransferFunction(filepath); + core::TransferFunction tfn = tsd::io::importTransferFunction(filepath); if (tfn.colorPoints.empty() || tfn.opacityPoints.empty()) { tsd::core::logError( diff --git a/tsd/src/tsd/ui/imgui/windows/Viewport.cpp b/tsd/src/tsd/ui/imgui/windows/Viewport.cpp index 3d0d844ed..1ed76d104 100644 --- a/tsd/src/tsd/ui/imgui/windows/Viewport.cpp +++ b/tsd/src/tsd/ui/imgui/windows/Viewport.cpp @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 #include "Viewport.h" +// tsd_app +#include "tsd/app/ANARIDeviceManager.h" // tsd_ui_imgui #include "imgui.h" #include "tsd/ui/imgui/Application.h" @@ -9,6 +11,7 @@ // tsd_core #include "tsd/core/Logging.hpp" #include "tsd/scene/objects/Camera.hpp" +#include "tsd/scene/objects/Renderer.hpp" // tsd_io #include "tsd/io/serialization.hpp" // tsd_rendering @@ -23,6 +26,7 @@ #include #include #include +#include namespace tsd::ui::imgui { @@ -47,6 +51,16 @@ bool deviceSupportsExtension(anari::Device d, const char *extension) return false; } +std::string defaultLibraryName(const tsd::app::ANARIDeviceManager &adm) +{ + for (const auto &libName : adm.libraryList()) { + if (adm.isLoadableLibrary(libName)) + return libName; + } + + return {}; +} + } // namespace Viewport::Viewport( @@ -120,23 +134,40 @@ void Viewport::buildUI() } } -void Viewport::setLibrary(const std::string &libName) +void Viewport::setLibrary(const std::string &libName, size_t rendererIndex) { teardownDevice(); - if (!libName.empty() && libName != "{none}") { + auto &adm = appContext()->anari; + if (adm.isLoadableLibrary(libName)) { tsd::core::logStatus( "[viewport] *** setting viewport to use ANARI device '%s' ***", libName.c_str()); } - auto updateLibrary = [&, libName = libName]() { - auto &adm = appContext()->anari; + auto updateLibrary = [&, libName = libName, rendererIndex = rendererIndex]() { auto &scene = appContext()->tsd.scene; auto start = std::chrono::steady_clock::now(); - auto d = adm.loadDevice(libName); - m_libName = libName; + auto selectedLibName = libName; + auto d = adm.loadDevice(selectedLibName); + + if (!d && adm.isLoadableLibrary(selectedLibName)) { + tsd::core::logWarning( + "[viewport] failed to load ANARI device '%s'; falling back to a " + "default device", + selectedLibName.c_str()); + } + + if (!d) { + const auto fallbackLibName = defaultLibraryName(adm); + if (!fallbackLibName.empty() && fallbackLibName != selectedLibName) { + selectedLibName = fallbackLibName; + d = adm.loadDevice(selectedLibName); + } + } + + m_libName = d ? selectedLibName : std::string{}; m_latestFL = 0.f; m_minFL.reset(); @@ -154,14 +185,29 @@ void Viewport::setLibrary(const std::string &libName) tsd::core::logStatus("[viewport] setting up renderer objects..."); - m_renderers.objects = scene.renderersOfDevice(libName); + m_renderers.objects = scene.renderersOfDevice(selectedLibName); if (m_renderers.objects.empty()) - m_renderers.objects = scene.createStandardRenderers(libName, d); - m_renderers.current = m_renderers.objects[0]; + m_renderers.objects = scene.createStandardRenderers(selectedLibName, d); + + if (rendererIndex != TSD_INVALID_INDEX) { + auto renderer = scene.getObject(rendererIndex); + if (renderer && renderer->rendererDeviceName() == selectedLibName) + m_renderers.current = renderer; + else { + tsd::core::logWarning( + "[viewport] renderer object index %zu is unavailable for ANARI " + "device '%s'; using the default renderer", + rendererIndex, + selectedLibName.c_str()); + } + } + + if (!m_renderers.current && !m_renderers.objects.empty()) + m_renderers.current = m_renderers.objects[0]; tsd::core::logStatus("[viewport] populating render index..."); - m_rIdx = adm.acquireRenderIndex(scene, libName, d); + m_rIdx = adm.acquireRenderIndex(scene, selectedLibName, d); setSelectionVisibilityFilterEnabled(m_showOnlySelected); static bool firstFrame = true; @@ -195,7 +241,8 @@ void Viewport::setLibrary(const std::string &libName) tsd::core::logStatus("[viewport] warming up first frame..."); m_rIdx->computeDefaultView(); - m_anariPass->startFirstFrame(true); + if (m_renderingEnabled) + m_anariPass->startFirstFrame(true); viewport_setActive(true); tsd::core::logStatus("[viewport] ...device load complete"); @@ -221,6 +268,16 @@ void Viewport::setLibraryToDefault() : ""); } +const std::string &Viewport::libraryName() const +{ + return m_libName; +} + +size_t Viewport::currentRendererObjectIndex() const +{ + return m_renderers.current ? m_renderers.current->index() : TSD_INVALID_INDEX; +} + void Viewport::setDeviceChangeCb(ViewportDeviceChangeCb cb) { m_deviceChangeCb = std::move(cb); @@ -259,6 +316,8 @@ void Viewport::refreshCurrentDevice() void Viewport::saveSettings(tsd::core::DataNode &root) { root["anariLibrary"] = m_libName; + root["rendererObjectIndex"] = + static_cast(currentRendererObjectIndex()); // Viewport settings // @@ -324,7 +383,9 @@ void Viewport::loadSettings(tsd::core::DataNode &root) if (m_app->commandLineOptions()->useDefaultRenderer) { std::string libraryName; root["anariLibrary"].getValue(ANARI_STRING, &libraryName); - setLibrary(libraryName); + auto rendererIndex = + root["rendererObjectIndex"].getValueOr(TSD_INVALID_INDEX); + setLibrary(libraryName, rendererIndex); } } @@ -335,6 +396,7 @@ void Viewport::imagePipeline_populate(tsd::rendering::ImagePipeline &p) m_timeToLoadDevice); m_anariPass = p.emplace_back(m_device); + m_anariPass->setEnabled(m_renderingEnabled); m_anariPass->setUseImplicitAspectRatio(m_camera.useImplicitAspectRatio); m_saveToFilePass = p.emplace_back(); @@ -456,14 +518,35 @@ void Viewport::imagePipeline_populate(tsd::rendering::ImagePipeline &p) syncImagePassState(); } +void Viewport::setRenderingEnabled(bool enabled) +{ + m_renderingEnabled = enabled; + if (m_anariPass) + m_anariPass->setEnabled(enabled); +} + +void Viewport::releaseSceneReferences() +{ + teardownDevice(); +} + void Viewport::camera_resetView(bool resetAzEl) { + const auto mode = m_camera.arcball->mode(); auto axis = m_camera.arcball->axis(); auto azel = resetAzEl ? tsd::math::float2(0.f, 20.f) : m_camera.arcball->azel(); - m_camera.arcball->setConfig(m_rIdx->computeDefaultView()); - m_camera.arcball->setAzel(azel); - m_camera.arcball->setAxis(axis); + auto pose = m_rIdx->computeDefaultView(); + pose.mode = static_cast(mode); + pose.upAxis = static_cast(axis); + if (mode == tsd::rendering::ManipulatorMode::Look && !resetAzEl) { + m_camera.arcball->setDistance(pose.azeldist.z); + m_camera.arcball->setFixedDistance(pose.fixedDist); + } else { + m_camera.arcball->setConfig(pose); + m_camera.arcball->setFixedDistance(pose.fixedDist); + m_camera.arcball->setAzel(azel); + } m_camera.arcballToken = 0; } @@ -471,14 +554,23 @@ void Viewport::camera_centerView() { if (!BaseViewport::viewport_isActive()) return; + const auto mode = m_camera.arcball->mode(); auto axis = m_camera.arcball->axis(); auto azel = m_camera.arcball->azel(); auto dist = m_camera.arcball->distance(); auto fixedDist = m_camera.arcball->fixedDistance(); - m_camera.arcball->setConfig(m_rIdx->computeDefaultView()); - m_camera.arcball->setAzel(azel); - m_camera.arcball->setDistance(dist); - m_camera.arcball->setFixedDistance(fixedDist); + auto pose = m_rIdx->computeDefaultView(); + pose.mode = static_cast(mode); + pose.upAxis = static_cast(axis); + if (mode == tsd::rendering::ManipulatorMode::Look) { + m_camera.arcball->setCenter(pose.lookat); + m_camera.arcball->setFixedDistance(fixedDist); + } else { + m_camera.arcball->setConfig(pose); + m_camera.arcball->setAzel(azel); + m_camera.arcball->setDistance(dist); + m_camera.arcball->setFixedDistance(fixedDist); + } m_camera.arcball->setAxis(axis); m_camera.arcballToken = 0; } @@ -516,12 +608,14 @@ void Viewport::renderer_resetParameterDefaults() void Viewport::teardownDevice() { - if (!BaseViewport::imagePipeline_isSetup()) - return; + const bool pipelineSetup = BaseViewport::imagePipeline_isSetup(); - BaseViewport::viewport_setActive(false); - BaseViewport::imagePipeline_teardown(); - BaseViewport::viewport_reshape(tsd::math::int2(1, 1)); + if (pipelineSetup) { + BaseViewport::viewport_setActive(false); + BaseViewport::imagePipeline_teardown(); + BaseViewport::viewport_reshape(tsd::math::int2(1, 1)); + } else + BaseViewport::viewport_setActive(false); m_anariPass = nullptr; m_pickPass = nullptr; @@ -534,14 +628,16 @@ void Viewport::teardownDevice() m_outputPass = nullptr; m_saveToFilePass = nullptr; - appContext()->anari.releaseRenderIndex(appContext()->tsd.scene, m_device); + if (m_rIdx) + appContext()->anari.releaseRenderIndex(appContext()->tsd.scene, m_device); m_rIdx = nullptr; m_libName.clear(); m_camera.current = {}; m_prevCamera = {}; - anari::release(m_device, m_device); + if (m_device) + anari::release(m_device, m_device); m_renderers.objects.clear(); m_renderers.current = nullptr; diff --git a/tsd/src/tsd/ui/imgui/windows/Viewport.h b/tsd/src/tsd/ui/imgui/windows/Viewport.h index 2f9ea833c..3fa204456 100644 --- a/tsd/src/tsd/ui/imgui/windows/Viewport.h +++ b/tsd/src/tsd/ui/imgui/windows/Viewport.h @@ -41,12 +41,17 @@ struct Viewport : public BaseViewport ~Viewport(); void buildUI() override; - void setLibrary(const std::string &libName); + void setLibrary( + const std::string &libName, size_t rendererIndex = TSD_INVALID_INDEX); void setLibraryToDefault(); + const std::string &libraryName() const; + size_t currentRendererObjectIndex() const; void setDeviceChangeCb(ViewportDeviceChangeCb cb); void setExternalInstances( const anari::Instance *instances = nullptr, size_t count = 0); void setCustomFrameParameter(const char *name, const tsd::core::Any &value); + void setRenderingEnabled(bool enabled); + void releaseSceneReferences(); private: void refreshCurrentDevice(); @@ -93,6 +98,7 @@ struct Viewport : public BaseViewport tsd::app::RenderIndexKind::ALL_LAYERS}; bool m_showOverlay{true}; + bool m_renderingEnabled{true}; bool m_highlightSelection{true}; bool m_outlinePrimitives{false}; bool m_showOnlySelected{false}; diff --git a/tsd/src/tsd/ui/imgui/windows/Window.cpp b/tsd/src/tsd/ui/imgui/windows/Window.cpp index 8c827b20f..f9eac13b6 100644 --- a/tsd/src/tsd/ui/imgui/windows/Window.cpp +++ b/tsd/src/tsd/ui/imgui/windows/Window.cpp @@ -21,7 +21,10 @@ void Window::renderUI() return; ImGui::SetNextWindowSize(ImVec2(550, 680), ImGuiCond_FirstUseEver); + const int styleCount = pushStyle(); ImGui::Begin(m_name.c_str(), &m_visible, windowFlags()); + if (styleCount > 0) + ImGui::PopStyleVar(styleCount); buildUI(); ImGui::End(); } @@ -66,6 +69,11 @@ ImGuiWindowFlags Window::windowFlags() const return 0; } +int Window::pushStyle() +{ + return 0; +} + tsd::app::Context *Window::appContext() const { return m_app ? m_app->appContext() : nullptr; diff --git a/tsd/src/tsd/ui/imgui/windows/Window.h b/tsd/src/tsd/ui/imgui/windows/Window.h index 977033910..8b723378a 100644 --- a/tsd/src/tsd/ui/imgui/windows/Window.h +++ b/tsd/src/tsd/ui/imgui/windows/Window.h @@ -40,6 +40,7 @@ struct Window protected: virtual int windowFlags() const; + virtual int pushStyle(); tsd::app::Context *appContext() const; Application *m_app{nullptr}; diff --git a/tsd/tests/CMakeLists.txt b/tsd/tests/CMakeLists.txt index 7d71f2565..dcb2f7a5a 100644 --- a/tsd/tests/CMakeLists.txt +++ b/tsd/tests/CMakeLists.txt @@ -15,6 +15,8 @@ project_add_executable( test_FlatMap.cpp test_Forest.cpp test_Geometry.cpp + test_Importers.cpp + test_Manipulator.cpp test_ObjectPool.cpp test_Material.cpp test_Math.cpp @@ -22,16 +24,24 @@ project_add_executable( test_ObjectUsePtr.cpp test_Parameter.cpp test_Scene.cpp + test_Serialization.cpp test_Token.cpp ) +if (TARGET tsd_scivis_studio_model) + target_sources(${PROJECT_NAME} PRIVATE test_SciVisStudio.cpp) +endif() project_link_libraries( PRIVATE tsd_algorithms tsd_animation tsd_io + tsd_rendering tsd_scene tsd_ext_catch2 ) +if (TARGET tsd_scivis_studio_model) + target_link_libraries(${PROJECT_NAME} PRIVATE tsd_scivis_studio_model) +endif() add_test(NAME tsd::AnimationManager COMMAND ${PROJECT_NAME} "[AnimationManager]") add_test(NAME tsd::Algorithms COMMAND ${PROJECT_NAME} "[Algorithms]" ) @@ -41,6 +51,8 @@ add_test(NAME tsd::DataTree COMMAND ${PROJECT_NAME} "[DataTree]" ) add_test(NAME tsd::FlatMap COMMAND ${PROJECT_NAME} "[FlatMap]" ) add_test(NAME tsd::Forest COMMAND ${PROJECT_NAME} "[Forest]" ) add_test(NAME tsd::Geometry COMMAND ${PROJECT_NAME} "[Geometry]" ) +add_test(NAME tsd::Importers COMMAND ${PROJECT_NAME} "[Importers]" ) +add_test(NAME tsd::Manipulator COMMAND ${PROJECT_NAME} "[Manipulator]" ) add_test(NAME tsd::ObjectPool COMMAND ${PROJECT_NAME} "[ObjectPool]" ) add_test(NAME tsd::Material COMMAND ${PROJECT_NAME} "[Material]" ) add_test(NAME tsd::Math COMMAND ${PROJECT_NAME} "[Math]" ) @@ -48,4 +60,8 @@ add_test(NAME tsd::Object COMMAND ${PROJECT_NAME} "[Object]" ) add_test(NAME tsd::ObjectUsePtr COMMAND ${PROJECT_NAME} "[ObjectUsePtr]" ) add_test(NAME tsd::Parameter COMMAND ${PROJECT_NAME} "[Parameter]" ) add_test(NAME tsd::Scene COMMAND ${PROJECT_NAME} "[Scene]" ) +add_test(NAME tsd::Serialization COMMAND ${PROJECT_NAME} "[Serialization]") add_test(NAME tsd::Token COMMAND ${PROJECT_NAME} "[Token]" ) +if (TARGET tsd_scivis_studio_model) + add_test(NAME tsd::SciVisStudio COMMAND ${PROJECT_NAME} "[SciVisStudio]") +endif() diff --git a/tsd/tests/test_DataTree.cpp b/tsd/tests/test_DataTree.cpp index cb0bab63c..157abcd7f 100644 --- a/tsd/tests/test_DataTree.cpp +++ b/tsd/tests/test_DataTree.cpp @@ -6,6 +6,7 @@ // tsd #define TSD_DATA_TREE_TEST_MODE #include "tsd/core/DataTree.hpp" +#include "tsd/core/DataTreeMetadata.hpp" // std #include @@ -252,3 +253,82 @@ SCENARIO("tsd::core::DataTree interface", "[DataTree]") } } } + +SCENARIO("tsd::core::DataTree metadata helpers", "[DataTree]") +{ + GIVEN("An empty DataTree") + { + tsd::core::DataTree tree; + auto &root = tree.root(); + + THEN("metadata is reported as missing") + { + auto result = tsd::core::readDataTreeMetadata(root); + REQUIRE(result.status == tsd::core::DataTreeMetadataReadStatus::Missing); + REQUIRE(!result.metadata); + } + + WHEN("metadata is written") + { + tsd::core::writeDataTreeMetadata( + root, {1, "scene", "tsd.scene.full", 1}); + + THEN("the required fields can be read back") + { + auto result = tsd::core::readDataTreeMetadata(root); + REQUIRE(result.status == tsd::core::DataTreeMetadataReadStatus::Found); + REQUIRE(result.metadata); + REQUIRE(result.metadata->envelopeVersion == 1); + REQUIRE(result.metadata->fileType == "scene"); + REQUIRE(result.metadata->schema == "tsd.scene.full"); + REQUIRE(result.metadata->schemaVersion == 1); + } + } + + WHEN("optional metadata is present") + { + root[tsd::core::DATA_TREE_METADATA_NODE]["producer"] = "test"; + tsd::core::writeDataTreeMetadata( + root, {1, "scene", "tsd.scene.full", 1}); + + THEN("writing required fields preserves optional fields") + { + auto *producer = + root[tsd::core::DATA_TREE_METADATA_NODE].child("producer"); + REQUIRE(producer != nullptr); + REQUIRE(producer->getValueAs() == "test"); + } + } + + WHEN("metadata is present but incomplete") + { + root[tsd::core::DATA_TREE_METADATA_NODE]["schema"] = "tsd.scene.full"; + + THEN("the metadata is rejected as malformed") + { + auto result = tsd::core::readDataTreeMetadata(root); + REQUIRE( + result.status == tsd::core::DataTreeMetadataReadStatus::Malformed); + REQUIRE(result.message.find("envelopeVersion") != std::string::npos); + } + } + + WHEN("metadata uses the wrong required field type") + { + auto &metadata = root[tsd::core::DATA_TREE_METADATA_NODE]; + metadata["envelopeVersion"] = "1"; + metadata["fileType"] = "scene"; + metadata["schema"] = "tsd.scene.full"; + metadata["schemaVersion"] = 1; + + THEN("the metadata is rejected as malformed") + { + auto result = tsd::core::readDataTreeMetadata(root); + REQUIRE( + result.status == tsd::core::DataTreeMetadataReadStatus::Malformed); + REQUIRE(result.message.find("envelopeVersion") != std::string::npos); + REQUIRE(result.message.find("got") != std::string::npos); + } + } + } +} diff --git a/tsd/tests/test_Importers.cpp b/tsd/tests/test_Importers.cpp new file mode 100644 index 000000000..ab31cd41c --- /dev/null +++ b/tsd/tests/test_Importers.cpp @@ -0,0 +1,66 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +// catch +#include "catch.hpp" +// tsd +#include "tsd/animation/AnimationManager.hpp" +#include "tsd/io/importers.hpp" +#include "tsd/io/importers/detail/importer_common.hpp" +#include "tsd/scene/Scene.hpp" +// std +#include +#include + +SCENARIO( + "Volume transfer functions reject missing control points", "[Importers]") +{ + tsd::scene::Scene scene; + auto volume = scene.createObject( + tsd::scene::tokens::volume::transferFunction1D); + tsd::core::TransferFunction transferFunction; + + WHEN("An empty transfer function is applied") + { + tsd::io::applyTransferFunction(scene, volume, transferFunction); + + THEN("The volume keeps its default scalar color") + { + REQUIRE(volume->parameterValueAsObject("color") + == nullptr); + } + } +} + +SCENARIO( + "Single volume file import uses a default transfer function", "[Importers]") +{ + const auto path = + std::filesystem::temp_directory_path() / "tsd_test_1x1x1_uint8.raw"; + { + std::ofstream file(path, std::ios::binary); + const unsigned char voxel = 255; + file.write(reinterpret_cast(&voxel), sizeof(voxel)); + } + + tsd::scene::Scene scene; + tsd::animation::AnimationManager animMgr(&scene); + + WHEN("A volume is imported through the single-file dispatcher") + { + tsd::io::import_file( + scene, animMgr, {tsd::io::ImporterType::VOLUME, path.string()}); + + THEN("The imported volume has a sampled color array") + { + REQUIRE(scene.numberOfObjects(ANARI_VOLUME) == 1); + auto volume = scene.getObject(0); + REQUIRE(volume); + auto *color = volume->parameterValueAsObject("color"); + REQUIRE(color != nullptr); + REQUIRE(color->size() == 256); + } + } + + std::filesystem::remove(path); +} diff --git a/tsd/tests/test_Manipulator.cpp b/tsd/tests/test_Manipulator.cpp new file mode 100644 index 000000000..a0033abb4 --- /dev/null +++ b/tsd/tests/test_Manipulator.cpp @@ -0,0 +1,55 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +// catch +#include "catch.hpp" +// tsd +#include "tsd/rendering/view/Manipulator.hpp" + +namespace math = tsd::math; +namespace rendering = tsd::rendering; + +static void requireNear(const math::float3 &a, const math::float3 &b) +{ + REQUIRE(math::neql(a.x, b.x, 1e-4f)); + REQUIRE(math::neql(a.y, b.y, 1e-4f)); + REQUIRE(math::neql(a.z, b.z, 1e-4f)); +} + +SCENARIO("Manipulator look mode preserves the camera anchor", "[Manipulator]") +{ + rendering::Manipulator m; + m.setConfig(math::float3(0.f), 5.f, math::float2(30.f, 20.f)); + + const auto orbitEye = m.eye(); + const auto orbitDir = m.dir(); + const auto orbitUp = m.up(); + + WHEN("look mode is enabled") + { + m.setMode(rendering::ManipulatorMode::Look); + + THEN("the rendered camera pose does not jump") + { + requireNear(m.eye(), orbitEye); + requireNear(m.dir(), orbitDir); + requireNear(m.up(), orbitUp); + } + + THEN("rotation keeps the camera position fixed") + { + m.startNewRotation(); + m.rotate(math::float2(0.1f, -0.05f)); + + requireNear(m.eye(), orbitEye); + } + + THEN("setting the center retargets without moving the camera") + { + m.setCenter(math::float3(1.f, 2.f, 3.f)); + + requireNear(m.eye(), orbitEye); + requireNear(m.at(), math::float3(1.f, 2.f, 3.f)); + } + } +} diff --git a/tsd/tests/test_Math.cpp b/tsd/tests/test_Math.cpp index 5e2aad539..3bb23022f 100644 --- a/tsd/tests/test_Math.cpp +++ b/tsd/tests/test_Math.cpp @@ -5,11 +5,54 @@ #include "catch.hpp" // tsd #include "tsd/core/TSDMath.hpp" +// std +#include namespace math = tsd::math; +static math::mat4 composeAzElRollTransform(const math::float3 &azelrot, + const math::float3 &scale, + const math::float3 &translation) +{ + auto rot = math::IDENTITY_MAT4; + rot = math::mul(rot, + math::rotation_matrix(math::rotation_quat( + math::float3(0.f, 1.f, 0.f), math::radians(azelrot.x)))); + rot = math::mul(rot, + math::rotation_matrix(math::rotation_quat( + math::float3(1.f, 0.f, 0.f), math::radians(azelrot.y)))); + rot = math::mul(rot, + math::rotation_matrix(math::rotation_quat( + math::float3(0.f, 0.f, 1.f), math::radians(azelrot.z)))); + + return math::mul(math::translation_matrix(translation), + math::mul(rot, math::scaling_matrix(scale))); +} + +static void requireMat4Near( + const math::mat4 &actual, const math::mat4 &expected, float eps = 1e-4f) +{ + for (int c = 0; c < 4; c++) { + for (int r = 0; r < 4; r++) { + CAPTURE(c, r, actual[c][r], expected[c][r]); + REQUIRE(std::abs(actual[c][r] - expected[c][r]) <= eps); + } + } +} + SCENARIO("Matrix decomposition test", "[Math]") { + GIVEN("Degree angles outside the UI range") + { + THEN("They normalize to the canonical 0 to 360 degree range") + { + REQUIRE(math::neql(math::normalizeDegrees(-90.f), 270.f)); + REQUIRE(math::neql(math::normalizeDegrees(450.f), 90.f)); + REQUIRE(math::normalizeDegrees(math::float3(-90.f, 360.f, 450.f)) + == math::float3(270.f, 0.f, 90.f)); + } + } + GIVEN("An SRT formulated matrix transform") { auto tl_in = math::float3(1.f, 2.f, 3.f); @@ -52,3 +95,29 @@ SCENARIO("Matrix decomposition test", "[Math]") } } } + +SCENARIO("Matrix decomposition preserves singular SRT rotations", "[Math]") +{ + GIVEN("An SRT transform at the azimuth/elevation/roll singularity") + { + auto sc_in = math::float3(1.f, 1.f, 1.f); + auto azelrot_in = math::float3(0.f, 90.f, 270.f); + auto tl_in = math::float3(0.f, 0.f, 0.f); + auto xfm = composeAzElRollTransform(azelrot_in, sc_in, tl_in); + + WHEN("The transform is decomposed to UI SRT and recomposed") + { + math::float3 sc_out, tl_out; + math::mat4 rot_out; + + math::decomposeMatrix(xfm, sc_out, rot_out, tl_out); + auto azelrot_out = math::degrees(math::matrixToAzElRoll(rot_out)); + auto roundtrip = composeAzElRollTransform(azelrot_out, sc_out, tl_out); + + THEN("The recomposed transform still matches the original") + { + requireMat4Near(roundtrip, xfm); + } + } + } +} diff --git a/tsd/tests/test_Scene.cpp b/tsd/tests/test_Scene.cpp index 9a303705a..5b7ae6f8e 100644 --- a/tsd/tests/test_Scene.cpp +++ b/tsd/tests/test_Scene.cpp @@ -6,9 +6,23 @@ // tsd #include "tsd/scene/Scene.hpp" #include "tsd/scene/UpdateDelegate.hpp" +// std +#include namespace { +void requireMat4Near(const tsd::math::mat4 &actual, + const tsd::math::mat4 &expected, + float eps = 1e-4f) +{ + for (int c = 0; c < 4; c++) { + for (int r = 0; r < 4; r++) { + CAPTURE(c, r, actual[c][r], expected[c][r]); + REQUIRE(std::abs(actual[c][r] - expected[c][r]) <= eps); + } + } +} + struct CountingDelegate : public tsd::scene::EmptyUpdateDelegate { CountingDelegate(int *objectAddedCount) : m_objectAddedCount(objectAddedCount) @@ -60,6 +74,35 @@ SCENARIO("tsd::scene::Scene owns an intrinsic update delegate root", "[Scene]") } } +SCENARIO( + "tsd::scene::LayerNodeData preserves singular SRT transforms", "[Scene]") +{ + GIVEN("A transform with elevation 90 and roll 270") + { + tsd::math::mat3 srt; + srt[0] = tsd::math::float3(1.f, 1.f, 1.f); + srt[1] = tsd::math::float3(0.f, 90.f, 270.f); + srt[2] = tsd::math::float3(0.f, 0.f, 0.f); + + tsd::scene::LayerNodeData source(nullptr, srt); + tsd::scene::LayerNodeData node(nullptr, source.getTransform()); + + WHEN("The transform is exposed as UI SRT and applied back") + { + auto uiSrt = node.getTransformSRT(); + node.setAsTransform(uiSrt); + + THEN("The UI SRT keeps the roll and the matrix does not move") + { + REQUIRE(tsd::math::neql(uiSrt[1].x, 0.f, 1e-3f)); + REQUIRE(tsd::math::neql(uiSrt[1].y, 90.f, 1e-3f)); + REQUIRE(tsd::math::neql(uiSrt[1].z, 270.f, 1e-3f)); + requireMat4Near(node.getTransform(), source.getTransform()); + } + } + } +} + SCENARIO("tsd::scene::Scene delegate registration controls live signaling", "[Scene]") { diff --git a/tsd/tests/test_SciVisStudio.cpp b/tsd/tests/test_SciVisStudio.cpp new file mode 100644 index 000000000..049d2ed3f --- /dev/null +++ b/tsd/tests/test_SciVisStudio.cpp @@ -0,0 +1,694 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "catch.hpp" + +#include "ProjectContext.h" +#include "ProjectSerialization.h" +#include "RenderShotCLI.h" + +#include "tsd/app/Context.h" +#include "tsd/core/DataTree.hpp" +#include "tsd/core/DataTreeMetadata.hpp" +#include "tsd/io/serialization.hpp" +#include "tsd/scene/UpdateDelegate.hpp" +#include "tsd/scene/objects/Light.hpp" + +#include +#include +#include + +using namespace tsd::scivis_studio; + + +namespace { + +struct CountingLayerUpdateDelegate : public tsd::scene::EmptyUpdateDelegate +{ + void signalLayerStructureUpdated(const tsd::scene::Layer *l) override + { + lastLayer = l; + layerStructureUpdates++; + } + + const tsd::scene::Layer *lastLayer{nullptr}; + int layerStructureUpdates{0}; +}; + +tsd::scene::LayerNodeRef findDirectChild( + tsd::scene::LayerNodeRef parent, const std::string &name) +{ + auto child = parent->next(); + while (child && child != parent) { + if ((*child)->name() == name) + return child; + child = child->sibling(); + } + return {}; +} + +} // namespace + +SCENARIO("SciVis Studio project model serialization", "[SciVisStudio]") +{ + GIVEN("A project with datasets, shots, light rigs, and camera keyframes") + { + Project project; + project.name = "RoundTrip"; + project.projectDirectory = "/tmp/roundtrip"; + project.datasets.push_back({"dataset_0001", + "Dataset", + DatasetSourceKind::Static, + "OBJ", + {"/tmp/data.obj", "data.obj", 100, 42}, + DatasetStatus::Available, + {"studio", 3}}); + project.datasets.front().sourceFiles.push_back( + {"/tmp/frame_0001.raw", "frames/frame_0001.raw", 101, 43}); + project.datasets.front().sourceFiles.push_back( + {"/tmp/frame_0002.raw", "frames/frame_0002.raw", 102, 44}); + + Shot shot; + shot.id = "shot_0001"; + shot.name = "Shot 1"; + shot.datasetBindings.push_back({"dataset_0001", true}); + shot.lightRigId = "lightRig_0001"; + shot.camera = {ANARI_CAMERA, 2}; + shot.renderSettings.rendererLibrary = "dummy_test_device"; + shot.renderSettings.rendererObjectIndex = 7; + shot.renderSettings.rendererSubtype = "dummy_test_renderer"; + CameraKeyframe keyframe; + keyframe.frame = 12; + keyframe.name = "mid"; + keyframe.manipulator.orbit.lookat = {1.f, 2.f, 3.f}; + keyframe.manipulator.orbit.azeldist = {10.f, 20.f, 30.f}; + keyframe.interpolationToNext = CameraInterpolation::EaseOutIn; + shot.cameraRig.keyframes.push_back(keyframe); + project.activeShotId = shot.id; + project.shots.push_back(shot); + project.lightRigs.push_back({"lightRig_0001", "Default", {"studio", 5}}); + + tsd::core::DataTree tree; + projectToNode(project, tree.root()["scivisStudio"]); + auto &serialized = tree.root()["scivisStudio"]; + + REQUIRE(serialized["datasets"].child(0)->child("rootNode") == nullptr); + REQUIRE(serialized["datasets"].child(0)->child("sourceFiles") != nullptr); + REQUIRE(serialized["shots"].child(0)->child("lightRigId") != nullptr); + REQUIRE(serialized["shots"].child(0)->child("camera") == nullptr); + REQUIRE(serialized["lightRigs"].child(0)->child("rootNode") == nullptr); + + Project loaded; + REQUIRE(nodeToProject(serialized, loaded)); + + THEN("IDs and keyframes survive round trip") + { + REQUIRE(loaded.datasets.size() == 1); + REQUIRE(loaded.datasets.front().id == "dataset_0001"); + REQUIRE(loaded.datasets.front().sourceFiles.size() == 2); + REQUIRE(loaded.datasets.front().sourceFiles.front().projectRelativePath + == "frames/frame_0001.raw"); + REQUIRE(loaded.shots.size() == 1); + REQUIRE(loaded.shots.front().id == "shot_0001"); + REQUIRE(loaded.shots.front().lightRigId == "lightRig_0001"); + REQUIRE(loaded.lightRigs.size() == 1); + REQUIRE(loaded.lightRigs.front().id == "lightRig_0001"); + REQUIRE(loaded.shots.front().renderSettings.rendererLibrary + == "dummy_test_device"); + REQUIRE(loaded.shots.front().renderSettings.rendererObjectIndex == 7); + REQUIRE(loaded.shots.front().renderSettings.rendererSubtype + == "dummy_test_renderer"); + REQUIRE(loaded.shots.front().cameraRig.keyframes.size() == 1); + REQUIRE(loaded.shots.front().cameraRig.keyframes.front().frame == 12); + REQUIRE( + loaded.shots.front().cameraRig.keyframes.front().interpolationToNext + == CameraInterpolation::EaseOutIn); + } + } +} + +SCENARIO("SciVis Studio source file resolution prefers project-relative paths", + "[SciVisStudio]") +{ + const auto root = + std::filesystem::temp_directory_path() / "tsd_scivis_studio_source_paths"; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root / "frames"); + + const auto relativeFrame = root / "frames" / "frame_0001.raw"; + { + std::ofstream out(relativeFrame); + out << "frame"; + } + + ProjectContext projectContext; + projectContext.project().projectDirectory = root; + DatasetSourceFile sourceFile; + sourceFile.absolutePath = "/missing/frame_0001.raw"; + sourceFile.projectRelativePath = "frames/frame_0001.raw"; + + REQUIRE(projectContext.resolveSourceFilePath(sourceFile) == relativeFrame); + REQUIRE(projectContext.sourceFileIsRegular(sourceFile)); + + std::filesystem::remove_all(root); +} + +SCENARIO("SciVis Studio camera interpolation modes", "[SciVisStudio]") +{ + GIVEN("Camera interpolation modes") + { + THEN("String conversion round-trips all persisted values") + { + const CameraInterpolation modes[] = {CameraInterpolation::Hold, + CameraInterpolation::Linear, + CameraInterpolation::EaseOut, + CameraInterpolation::EaseIn, + CameraInterpolation::EaseOutIn}; + + for (auto mode : modes) + REQUIRE(shot_camera_rig::interpolationFromString( + shot_camera_rig::toString(mode)) + == mode); + + REQUIRE(shot_camera_rig::interpolationFromString("Unknown") + == CameraInterpolation::Linear); + } + + THEN("Sampling applies easing to the segment interpolation factor") + { + ShotCameraRig rig; + + CameraKeyframe a; + a.frame = 0; + a.manipulator.orbit.lookat = {0.f, 0.f, 0.f}; + a.manipulator.orbit.azeldist = {0.f, 0.f, 0.f}; + a.manipulator.orbit.fixedDist = 0.f; + + CameraKeyframe b; + b.frame = 100; + b.manipulator.orbit.lookat = {100.f, 0.f, 0.f}; + b.manipulator.orbit.azeldist = {100.f, 0.f, 0.f}; + b.manipulator.orbit.fixedDist = 100.f; + + rig.keyframes = {a, b}; + + rig.keyframes.front().interpolationToNext = CameraInterpolation::EaseOut; + REQUIRE(shot_camera_rig::sampleCameraRig(rig, 25).orbit.lookat.x == Approx(6.25f)); + + rig.keyframes.front().interpolationToNext = CameraInterpolation::EaseIn; + REQUIRE(shot_camera_rig::sampleCameraRig(rig, 25).orbit.lookat.x == Approx(57.8125f)); + + rig.keyframes.front().interpolationToNext = + CameraInterpolation::EaseOutIn; + REQUIRE(shot_camera_rig::sampleCameraRig(rig, 25).orbit.lookat.x == Approx(10.3515625f)); + REQUIRE(shot_camera_rig::sampleCameraRig(rig, 25).orbit.azeldist.x == Approx(10.3515625f)); + REQUIRE(shot_camera_rig::sampleCameraRig(rig, 25).orbit.fixedDist == Approx(10.3515625f)); + REQUIRE(shot_camera_rig::sampleCameraRig(rig, 75).orbit.lookat.x == Approx(89.6484375f)); + } + } +} + +SCENARIO("SciVis Studio project root validation", "[SciVisStudio]") +{ + const auto root = + std::filesystem::temp_directory_path() / "tsd_scivis_studio_test_project"; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + + GIVEN("A valid metadata-tagged project manifest") + { + tsd::core::DataTree tree; + tsd::core::writeDataTreeMetadata(tree.root(), + {tsd::core::DATA_TREE_METADATA_ENVELOPE_VERSION, + PROJECT_FILE_TYPE, + PROJECT_SCHEMA, + SCHEMA_VERSION}); + REQUIRE(tree.save((root / PROJECT_MANIFEST_FILENAME).string().c_str())); + + THEN("Validation succeeds") + { + auto result = validateProjectRoot(root); + REQUIRE(result.ok); + } + } + + GIVEN("A valid legacy project manifest") + { + tsd::core::DataTree tree; + tree.root()["projectKind"] = PROJECT_KIND; + tree.root()["schemaVersion"] = 1; + REQUIRE(tree.save((root / PROJECT_MANIFEST_FILENAME).string().c_str())); + + THEN("Validation succeeds") + { + auto result = validateProjectRoot(root); + REQUIRE(result.ok); + } + } + + GIVEN("An invalid metadata schema") + { + tsd::core::DataTree tree; + tsd::core::writeDataTreeMetadata(tree.root(), + {tsd::core::DATA_TREE_METADATA_ENVELOPE_VERSION, + "application-state", + "tsd.viewer.state", + 1}); + REQUIRE(tree.save((root / PROJECT_MANIFEST_FILENAME).string().c_str())); + + THEN("Validation fails") + { + auto result = validateProjectRoot(root); + REQUIRE_FALSE(result.ok); + } + } + + GIVEN("An invalid legacy project kind") + { + tsd::core::DataTree tree; + tree.root()["projectKind"] = "Other"; + tree.root()["schemaVersion"] = SCHEMA_VERSION; + REQUIRE(tree.save((root / PROJECT_MANIFEST_FILENAME).string().c_str())); + + THEN("Validation fails") + { + auto result = validateProjectRoot(root); + REQUIRE_FALSE(result.ok); + } + } + + GIVEN("A future metadata-tagged project manifest") + { + tsd::core::DataTree tree; + tsd::core::writeDataTreeMetadata(tree.root(), + {tsd::core::DATA_TREE_METADATA_ENVELOPE_VERSION, + PROJECT_FILE_TYPE, + PROJECT_SCHEMA, + SCHEMA_VERSION + 1}); + REQUIRE(tree.save((root / PROJECT_MANIFEST_FILENAME).string().c_str())); + + THEN("Validation fails") + { + auto result = validateProjectRoot(root); + REQUIRE_FALSE(result.ok); + } + } + + std::filesystem::remove_all(root); +} + +SCENARIO("SciVis Studio default project creation", "[SciVisStudio]") +{ + tsd::app::Context appContext; + ProjectContext projectContext(&appContext); + projectContext.createUnsavedProject(); + + auto &project = projectContext.project(); + REQUIRE(project.name == "Untitled"); + REQUIRE(project.shots.size() == 1); + REQUIRE(project.lightRigs.size() == 1); + REQUIRE(project.lightRigs.front().name == "Default"); + REQUIRE(project.shots.front().lightRigId == project.lightRigs.front().id); + REQUIRE(project.activeShotId == project.shots.front().id); + REQUIRE(project.dirty == false); + REQUIRE(appContext.tsd.scene.layer("studio") != nullptr); + + auto *layer = appContext.tsd.scene.layer("studio"); + auto lightRigsRoot = findDirectChild(layer->root(), "lightRigs"); + REQUIRE(lightRigsRoot); + auto rigRoot = findDirectChild(lightRigsRoot, project.lightRigs.front().id); + REQUIRE(rigRoot); + REQUIRE(findDirectChild(rigRoot, "mainLight")); +} + +SCENARIO("SciVis Studio new shots use the default light rig", "[SciVisStudio]") +{ + tsd::app::Context appContext; + ProjectContext projectContext(&appContext); + projectContext.createUnsavedProject(); + + const auto defaultRigId = projectContext.project().lightRigs.front().id; + REQUIRE(projectContext.addShot()); + REQUIRE(project::activeShot(projectContext.project())->lightRigId == defaultRigId); +} + +SCENARIO("SciVis Studio shot dataset bindings update scene visibility", + "[SciVisStudio]") +{ + tsd::app::Context appContext; + ProjectContext projectContext(&appContext); + projectContext.createUnsavedProject(); + + auto &scene = appContext.tsd.scene; + auto *layer = scene.layer("studio"); + REQUIRE(layer != nullptr); + + auto datasetRoot = scene.insertChildNode(layer->root(), "dataset_0001"); + REQUIRE(datasetRoot); + + auto &project = projectContext.project(); + project.datasets.push_back({"dataset_0001", + "Dataset", + DatasetSourceKind::Static, + "OBJ", + {}, + DatasetStatus::Available, + projectContext.refFor("studio", datasetRoot)}); + + auto &shot = *project::activeShot(project); + shot::setDatasetBinding(shot, "dataset_0001", false); + + auto *delegate = + scene.updateDelegate().emplace(); + + projectContext.applyActiveShot(); + + REQUIRE_FALSE((*datasetRoot)->isEnabled()); + REQUIRE(delegate->layerStructureUpdates == 1); + REQUIRE(delegate->lastLayer == layer); +} + +SCENARIO("SciVis Studio dataset binding resolves the dataset group by ID", + "[SciVisStudio]") +{ + tsd::app::Context appContext; + ProjectContext projectContext(&appContext); + projectContext.createUnsavedProject(); + + auto &scene = appContext.tsd.scene; + auto *layer = scene.layer("studio"); + REQUIRE(layer != nullptr); + + auto datasetsRoot = findDirectChild(layer->root(), "datasets"); + REQUIRE(datasetsRoot); + auto datasetRoot = scene.insertChildNode(datasetsRoot, "dataset_0001"); + auto importedFileRoot = scene.insertChildNode(datasetRoot, "imported.vtp"); + auto partRoot = scene.insertChildNode(importedFileRoot, "part_1"); + + auto &project = projectContext.project(); + project.datasets.push_back({"dataset_0001", + "Dataset", + DatasetSourceKind::Static, + "VTP", + {}, + DatasetStatus::Available, + projectContext.refFor("studio", partRoot)}); + + auto &shot = *project::activeShot(project); + shot::setDatasetBinding(shot, "dataset_0001", false); + + projectContext.applyActiveShot(); + + REQUIRE_FALSE((*datasetRoot)->isEnabled()); + REQUIRE((*importedFileRoot)->isEnabled()); + REQUIRE((*partRoot)->isEnabled()); + REQUIRE(project.datasets.front().rootNode.nodeIndex == datasetRoot.index()); +} + +SCENARIO("SciVis Studio saved projects rebuild runtime refs from stable IDs", + "[SciVisStudio]") +{ + const auto root = + std::filesystem::temp_directory_path() / "tsd_scivis_studio_runtime_refs"; + std::filesystem::remove_all(root); + + { + tsd::app::Context appContext; + ProjectContext projectContext(&appContext); + projectContext.createUnsavedProject(); + + auto &scene = appContext.tsd.scene; + auto *layer = scene.layer("studio"); + REQUIRE(layer != nullptr); + auto datasetsRoot = findDirectChild(layer->root(), "datasets"); + REQUIRE(datasetsRoot); + auto datasetRoot = scene.insertChildNode(datasetsRoot, "dataset_0001"); + scene.insertChildNode(datasetRoot, "imported.vtp"); + + auto &project = projectContext.project(); + project.datasets.push_back({"dataset_0001", + "Dataset", + DatasetSourceKind::Static, + "VTP", + {}, + DatasetStatus::Available, + projectContext.refFor("studio", datasetRoot)}); + shot::setDatasetBinding(*project::activeShot(project), "dataset_0001", false); + + REQUIRE(projectContext.saveProject(root)); + } + + { + tsd::core::DataTree manifest; + REQUIRE(manifest.load((root / PROJECT_MANIFEST_FILENAME).string().c_str())); + auto metadata = tsd::core::readDataTreeMetadata(manifest.root()); + REQUIRE(metadata.status == tsd::core::DataTreeMetadataReadStatus::Found); + REQUIRE(metadata.metadata); + REQUIRE(metadata.metadata->fileType == PROJECT_FILE_TYPE); + REQUIRE(metadata.metadata->schema == PROJECT_SCHEMA); + REQUIRE(metadata.metadata->schemaVersion == SCHEMA_VERSION); + REQUIRE(manifest.root().child("projectKind") == nullptr); + REQUIRE(manifest.root().child("schemaVersion") == nullptr); + + auto &projectNode = manifest.root()["scivisStudio"]; + REQUIRE(projectNode["datasets"].child(0)->child("rootNode") == nullptr); + REQUIRE(projectNode["shots"].child(0)->child("lightRigId") != nullptr); + REQUIRE(projectNode["shots"].child(0)->child("camera") == nullptr); + REQUIRE(projectNode["lightRigs"].child(0)->child("rootNode") == nullptr); + } + + { + tsd::app::Context appContext; + ProjectContext projectContext(&appContext); + REQUIRE(projectContext.openProject(root)); + + auto *layer = appContext.tsd.scene.layer("studio"); + REQUIRE(layer != nullptr); + auto datasetsRoot = findDirectChild(layer->root(), "datasets"); + auto datasetRoot = findDirectChild(datasetsRoot, "dataset_0001"); + REQUIRE(datasetRoot); + REQUIRE_FALSE((*datasetRoot)->isEnabled()); + } + + std::filesystem::remove_all(root); +} + +SCENARIO( + "SciVis Studio active shot toggles light rig visibility", "[SciVisStudio]") +{ + tsd::app::Context appContext; + ProjectContext projectContext(&appContext); + projectContext.createUnsavedProject(); + + auto &project = projectContext.project(); + auto &firstShot = project.shots.front(); + auto *defaultRig = project::findLightRig(project, firstShot.lightRigId); + REQUIRE(defaultRig != nullptr); + auto defaultRoot = projectContext.resolveLightRigRoot(*defaultRig); + REQUIRE(defaultRoot); + + auto *secondRig = projectContext.createLightRig("Second"); + REQUIRE(secondRig != nullptr); + auto secondRoot = projectContext.resolveLightRigRoot(*secondRig); + REQUIRE(secondRoot); + + projectContext.addShot("Second Shot"); + auto &secondShot = *project::activeShot(project); + secondShot.lightRigId = secondRig->id; + projectContext.applyActiveShot(); + + REQUIRE_FALSE((*defaultRoot)->isEnabled()); + REQUIRE((*secondRoot)->isEnabled()); + + secondShot.lightRigId.clear(); + projectContext.applyActiveShot(); + REQUIRE_FALSE((*defaultRoot)->isEnabled()); + REQUIRE_FALSE((*secondRoot)->isEnabled()); + + secondShot.lightRigId = "missing"; + projectContext.applyActiveShot(); + REQUIRE_FALSE((*defaultRoot)->isEnabled()); + REQUIRE_FALSE((*secondRoot)->isEnabled()); +} + +SCENARIO("SciVis Studio removing a light rig clears shot references", + "[SciVisStudio]") +{ + tsd::app::Context appContext; + ProjectContext projectContext(&appContext); + projectContext.createUnsavedProject(); + + auto &project = projectContext.project(); + const auto rigId = project.lightRigs.front().id; + auto *rig = project::findLightRig(project, rigId); + REQUIRE(rig != nullptr); + auto root = projectContext.resolveLightRigRoot(*rig); + REQUIRE(root); + + REQUIRE(projectContext.removeLightRig(rigId)); + REQUIRE(project.lightRigs.empty()); + REQUIRE(project.shots.front().lightRigId.empty()); + auto *layer = appContext.tsd.scene.layer("studio"); + auto lightRigsRoot = findDirectChild(layer->root(), "lightRigs"); + REQUIRE_FALSE(findDirectChild(lightRigsRoot, rigId)); +} + +SCENARIO("SciVis Studio v1 shot lights migrate to light rigs", "[SciVisStudio]") +{ + const auto root = + std::filesystem::temp_directory_path() / "tsd_scivis_studio_v1_migrate"; + std::filesystem::remove_all(root); + + { + tsd::app::Context appContext; + ProjectContext projectContext(&appContext); + projectContext.createUnsavedProject(); + auto &project = projectContext.project(); + project.lightRigs.clear(); + project.shots.front().lightRigId.clear(); + + auto *layer = appContext.tsd.scene.layer("studio"); + auto shotsRoot = findDirectChild(layer->root(), "shots"); + auto shotRoot = findDirectChild(shotsRoot, project.shots.front().id); + auto legacyLights = + appContext.tsd.scene.insertChildNode(shotRoot, "lights"); + auto light = appContext.tsd.scene.createObject( + tsd::scene::tokens::light::directional); + light->setName("legacyLight"); + appContext.tsd.scene.insertChildObjectNode( + legacyLights, light, "legacyLight"); + + tsd::core::DataTree tree; + tsd::core::writeDataTreeMetadata(tree.root(), + {tsd::core::DATA_TREE_METADATA_ENVELOPE_VERSION, + PROJECT_FILE_TYPE, + PROJECT_SCHEMA, + 1}); + projectToNode(project, tree.root()["scivisStudio"]); + tsd::io::save_Scene(appContext.tsd.scene, + tree.root()["context"], + false, + &appContext.tsd.animationMgr); + std::filesystem::create_directories(root); + REQUIRE(tree.save((root / PROJECT_MANIFEST_FILENAME).string().c_str())); + } + + { + tsd::app::Context appContext; + ProjectContext projectContext(&appContext); + REQUIRE(projectContext.openProject(root)); + auto &project = projectContext.project(); + REQUIRE(project.lightRigs.size() == 1); + REQUIRE(project.shots.front().lightRigId == project.lightRigs.front().id); + + auto rigRoot = + projectContext.resolveLightRigRoot(project.lightRigs.front()); + REQUIRE(rigRoot); + REQUIRE(findDirectChild(rigRoot, "legacyLight")); + } + + std::filesystem::remove_all(root); +} + +SCENARIO("SciVis Studio shot time is driven by the animation manager", + "[SciVisStudio]") +{ + tsd::app::Context appContext; + ProjectContext projectContext(&appContext); + projectContext.createUnsavedProject(); + + auto &shot = *project::activeShot(projectContext.project()); + shot.frameCount = 24; + shot.fps = 12.f; + shot.currentFrame = 4; + shot.loop = false; + projectContext.syncAnimationManagerToActiveShot(); + + auto &animMgr = appContext.tsd.animationMgr; + REQUIRE(animMgr.getAnimationTotalFrames() == 24); + REQUIRE(animMgr.getAnimationFPS() == Approx(12.f)); + REQUIRE(animMgr.getAnimationFrame() == 4); + REQUIRE_FALSE(animMgr.isLoop()); + + animMgr.setAnimationFrame(9); + REQUIRE(shot.currentFrame == 9); +} + +SCENARIO("SciVis Studio render-shot CLI parses command line", "[SciVisStudio]") +{ + RenderShotCommandLine commandLine; + std::string error; + + REQUIRE(parseRenderShotCommandLine( + {"scivisStudioRenderShot", "/tmp/project", "--shot", "shot_0002"}, + commandLine, + error)); + REQUIRE( + commandLine.projectDirectory == std::filesystem::path("/tmp/project")); + REQUIRE(commandLine.shotId == "shot_0002"); + REQUIRE_FALSE(commandLine.showHelp); + + REQUIRE(parseRenderShotCommandLine( + {"scivisStudioRenderShot", "--help"}, commandLine, error)); + REQUIRE(commandLine.showHelp); + + REQUIRE_FALSE(parseRenderShotCommandLine( + {"scivisStudioRenderShot", "/tmp/project", "--shot"}, + commandLine, + error)); + REQUIRE(error.find("--shot requires") != std::string::npos); +} + +SCENARIO("SciVis Studio render-shot CLI selects shots", "[SciVisStudio]") +{ + Project project; + project.shots.push_back({"shot_0001", "Overview"}); + project.shots.push_back({"shot_0002", "Detail"}); + + std::string error; + std::istringstream emptyInput; + std::ostringstream output; + + auto *shot = selectShotForRender( + project, "shot_0002", false, emptyInput, output, error); + REQUIRE(shot != nullptr); + REQUIRE(shot->id == "shot_0002"); + + shot = + selectShotForRender(project, "missing", false, emptyInput, output, error); + REQUIRE(shot == nullptr); + REQUIRE(error.find("unknown shot ID: missing") != std::string::npos); + REQUIRE(error.find("shot_0001") != std::string::npos); + + shot = selectShotForRender(project, "", false, emptyInput, output, error); + REQUIRE(shot == nullptr); + REQUIRE(error.find("multiple shots found") != std::string::npos); + REQUIRE(error.find("--shot ") != std::string::npos); + + std::istringstream selectionInput("2\n"); + output.str(""); + output.clear(); + shot = selectShotForRender(project, "", true, selectionInput, output, error); + REQUIRE(shot != nullptr); + REQUIRE(shot->id == "shot_0002"); + REQUIRE(output.str().find("Select shot [1-2]") != std::string::npos); + + std::istringstream invalidInput("3\n"); + shot = selectShotForRender(project, "", true, invalidInput, output, error); + REQUIRE(shot == nullptr); + REQUIRE(error.find("invalid shot selection: 3") != std::string::npos); +} + +SCENARIO( + "SciVis Studio render-shot CLI auto-selects one shot", "[SciVisStudio]") +{ + Project project; + project.shots.push_back({"shot_0001", "Only Shot"}); + + std::string error; + std::istringstream input; + std::ostringstream output; + auto *shot = selectShotForRender(project, "", false, input, output, error); + REQUIRE(shot != nullptr); + REQUIRE(shot->id == "shot_0001"); +} diff --git a/tsd/tests/test_Serialization.cpp b/tsd/tests/test_Serialization.cpp new file mode 100644 index 000000000..aec0fe3c5 --- /dev/null +++ b/tsd/tests/test_Serialization.cpp @@ -0,0 +1,645 @@ +// Copyright 2024-2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +// catch +#include "catch.hpp" +// tsd +#include "tsd/core/DataTreeMetadata.hpp" +#include "tsd/core/DataTree.hpp" +#include "tsd/io/serialization.hpp" +#include "tsd/scene/Scene.hpp" +// std +#include +#include +#include +#include + +namespace { + +std::string testFile(const char *name) +{ + return (std::filesystem::temp_directory_path() / name).string(); +} + +void removeTestFile(const std::string &filename) +{ + std::remove(filename.c_str()); +} + +tsd::scene::ArrayRef makeFloatArray( + tsd::scene::Scene &scene, const char *name, const std::vector &values) +{ + auto array = scene.createArray(ANARI_FLOAT32, values.size()); + array->setName(name); + array->setData(values); + return array; +} + +} // namespace + +SCENARIO("tsd::io camera and renderer subset serialization", "[Serialization]") +{ + GIVEN("A scene with cameras, renderers, and unrelated scene data") + { + tsd::scene::Scene source; + + auto defaultCamera = source.defaultCamera(); + defaultCamera->setName("shot_0_camera"); + defaultCamera->setParameter("fovy", 0.75f); + defaultCamera->setMetadataValue("exposure", 1.5f); + + auto secondCamera = source.createObject("orthographic"); + secondCamera->setName("shot_1_camera"); + secondCamera->setParameter("height", 12.f); + + auto renderer = source.createRenderer("test_device", "pathtracer"); + renderer->setName("shot_renderer"); + renderer->setParameter("pixelSamples", 8); + renderer->setMetadataValue("quality", 3); + + source.createObject("sphere"); + source.addLayer("preserved_source_layer"); + + tsd::core::DataTree tree; + auto &root = tree.root(); + root["layers"]["stale"] = "remove me"; + root["animations"]["stale"] = "remove me"; + + WHEN("only cameras and renderers are saved") + { + tsd::io::save_SceneCamerasAndRenderers(source, root); + + THEN("the output is tagged as a camera and renderer subset") + { + auto metadata = tsd::core::readDataTreeMetadata(root); + REQUIRE(metadata.status == tsd::core::DataTreeMetadataReadStatus::Found); + REQUIRE(metadata.metadata); + REQUIRE(metadata.metadata->schema + == std::string(tsd::io::schema::SCENE_CAMERAS_AND_RENDERERS)); + } + + THEN("the output contains only the camera and renderer object pools") + { + REQUIRE(root.child("layers") == nullptr); + REQUIRE(root.child("animations") == nullptr); + + auto *objectDB = root.child("objectDB"); + REQUIRE(objectDB != nullptr); + REQUIRE(objectDB->child("camera") != nullptr); + REQUIRE(objectDB->child("renderer") != nullptr); + REQUIRE(objectDB->child("geometry") == nullptr); + REQUIRE(objectDB->child("material") == nullptr); + } + + AND_WHEN("the subset is loaded into another populated scene") + { + tsd::scene::Scene target; + target.defaultCamera()->setName("old_default_camera"); + auto oldCamera = target.createObject("perspective"); + oldCamera->setName("old_extra_camera"); + auto oldRenderer = target.createRenderer("old_device", "old_renderer"); + oldRenderer->setName("old_renderer"); + target.createObject("cylinder"); + target.addLayer("keep_me"); + + tsd::io::load_SceneCamerasAndRenderers(target, root); + + THEN("only cameras and renderers are replaced") + { + REQUIRE(target.numberOfObjects(ANARI_GEOMETRY) == 1); + REQUIRE(target.numberOfLayers() == 1); + REQUIRE(target.layer("keep_me") != nullptr); + + REQUIRE(target.numberOfObjects(ANARI_CAMERA) == 2); + REQUIRE(target.numberOfObjects(ANARI_RENDERER) == 1); + REQUIRE(target.getObject(0)->name() + == "shot_0_camera"); + REQUIRE(target.getObject(1)->name() + == "shot_1_camera"); + REQUIRE(target.getObject(0)->name() + == "shot_renderer"); + } + + THEN("camera and renderer object data round-trips") + { + auto camera = target.getObject(0); + REQUIRE(camera); + REQUIRE(camera->subtype().str() == "perspective"); + REQUIRE(camera->parameter("fovy")->value().getAs() == 0.75f); + REQUIRE(camera->getMetadataValue("exposure").getAs() == 1.5f); + + auto second = target.getObject(1); + REQUIRE(second); + REQUIRE(second->subtype().str() == "orthographic"); + REQUIRE(second->parameter("height")->value().getAs() == 12.f); + + auto restoredRenderer = target.getObject(0); + REQUIRE(restoredRenderer); + REQUIRE(restoredRenderer->subtype().str() == "pathtracer"); + REQUIRE(restoredRenderer->rendererDeviceName().str() == "test_device"); + REQUIRE(restoredRenderer->parameter("pixelSamples") + ->value() + .getAs() + == 8); + REQUIRE(restoredRenderer->getMetadataValue("quality").getAs() + == 3); + } + } + } + } + + GIVEN("An empty camera subset") + { + tsd::scene::Scene scene; + tsd::core::DataTree tree; + tree.root()["objectDB"]; + + WHEN("the subset is loaded") + { + tsd::io::load_SceneCamerasAndRenderers(scene, tree.root()); + + THEN("the scene still has a default camera") + { + REQUIRE(scene.defaultCamera()); + REQUIRE(scene.numberOfObjects(ANARI_CAMERA) == 1); + } + } + } +} + +SCENARIO("tsd::io scene payload metadata validation", "[Serialization]") +{ + GIVEN("A serializable scene") + { + tsd::scene::Scene source; + source.defaultCamera()->setName("source_camera"); + auto renderer = source.createRenderer("test_device", "pathtracer"); + renderer->setName("source_renderer"); + + WHEN("a full scene is serialized") + { + tsd::core::DataTree tree; + tsd::io::save_Scene(source, tree.root(), false); + + THEN("the output is tagged as a full scene") + { + auto metadata = tsd::core::readDataTreeMetadata(tree.root()); + REQUIRE(metadata.status == tsd::core::DataTreeMetadataReadStatus::Found); + REQUIRE(metadata.metadata); + REQUIRE(metadata.metadata->schema + == std::string(tsd::io::schema::SCENE_FULL)); + } + + THEN("the camera and renderer subset loader accepts the full scene") + { + auto result = + tsd::io::validate_SceneCamerasAndRenderersPayload(tree.root()); + REQUIRE(result.accepted()); + REQUIRE(result.status == tsd::io::PayloadValidationStatus::Valid); + } + } + + WHEN("a camera and renderer subset is loaded as a full scene") + { + tsd::core::DataTree subsetTree; + tsd::io::save_SceneCamerasAndRenderers(source, subsetTree.root()); + + tsd::scene::Scene target; + target.createObject("sphere"); + target.addLayer("keep_me"); + + THEN("validation rejects it before mutation") + { + auto result = tsd::io::validate_ScenePayload(subsetTree.root()); + REQUIRE(!result.accepted()); + REQUIRE( + result.status == tsd::io::PayloadValidationStatus::IncompatibleSchema); + + tsd::io::load_Scene(target, subsetTree.root()); + REQUIRE(target.numberOfObjects(ANARI_GEOMETRY) == 1); + REQUIRE(target.numberOfLayers() == 1); + REQUIRE(target.layer("keep_me") != nullptr); + } + } + + WHEN("legacy metadata is missing but objectDB exists") + { + tsd::core::DataTree legacyTree; + legacyTree.root()["objectDB"]; + + THEN("validation accepts it as legacy") + { + auto result = tsd::io::validate_ScenePayload(legacyTree.root()); + REQUIRE(result.accepted()); + REQUIRE(result.status + == tsd::io::PayloadValidationStatus::MissingMetadataAccepted); + } + } + + WHEN("the payload is missing objectDB") + { + tsd::core::DataTree invalidTree; + tsd::core::writeDataTreeMetadata( + invalidTree.root(), {1, "scene", "tsd.scene.full", 1}); + + tsd::scene::Scene target; + target.createObject("sphere"); + target.addLayer("keep_me"); + + THEN("validation rejects it before mutation") + { + auto result = tsd::io::validate_ScenePayload(invalidTree.root()); + REQUIRE(!result.accepted()); + REQUIRE( + result.status == tsd::io::PayloadValidationStatus::MissingRequiredNode); + + tsd::io::load_Scene(target, invalidTree.root()); + REQUIRE(target.numberOfObjects(ANARI_GEOMETRY) == 1); + REQUIRE(target.numberOfLayers() == 1); + REQUIRE(target.layer("keep_me") != nullptr); + } + } + } +} + +SCENARIO("tsd::io surface object serialization", "[Serialization]") +{ + GIVEN("A surface with geometry, material, sampler, array data, and metadata") + { + tsd::scene::Scene source; + source.createSurface("unused_surface"); + + auto positions = + makeFloatArray(source, "positions", {1.f, 2.f, 3.f, 4.f, 5.f, 6.f}); + positions->setMetadataValue("stride", 12); + + auto texture = makeFloatArray(source, "texture", {0.25f, 0.5f, 0.75f}); + + auto sampler = + source.createObject(tsd::scene::tokens::sampler::image1D); + sampler->setName("albedo_sampler"); + sampler->setParameterObject("image", *texture); + + auto geometry = + source.createObject(tsd::scene::tokens::geometry::triangle); + geometry->setName("mesh_geometry"); + auto *positionParam = geometry->setParameterObject("vertex.position", *positions); + positionParam->setDescription("positions").setEnabled(false); + geometry->setMetadataValue("positionBuffer", + tsd::core::Any(positions->type(), positions->index())); + + auto material = + source.createObject(tsd::scene::tokens::material::matte); + material->setName("sampled_material"); + material->removeAllParameters(); + material->setParameterObject("color", *sampler); + material->setParameter("roughness", 0.35f); + material->setMetadataValue("samplerRef", + tsd::core::Any(sampler->type(), sampler->index())); + + auto surface = source.createSurface("root_surface", geometry, material); + surface->setMetadataValue("priority", 9); + surface->setMetadataValue("geometryRef", + tsd::core::Any(geometry->type(), geometry->index())); + + const auto filename = testFile("tsd_surface_object_roundtrip.tsd"); + removeTestFile(filename); + + WHEN("the surface is exported and imported into a non-empty scene") + { + REQUIRE(tsd::io::export_Object(filename.c_str(), *surface)); + + tsd::core::DataTree exportedTree; + REQUIRE(exportedTree.load(filename.c_str())); + + THEN("the payload is tagged as a surface object with local root index 0") + { + auto metadata = tsd::core::readDataTreeMetadata(exportedTree.root()); + REQUIRE(metadata.status == tsd::core::DataTreeMetadataReadStatus::Found); + REQUIRE(metadata.metadata); + REQUIRE(metadata.metadata->fileType == "object"); + REQUIRE(metadata.metadata->schema + == std::string(tsd::io::schema::OBJECT_SURFACE)); + + auto *rootObject = exportedTree.root().child("rootObject"); + REQUIRE(rootObject); + REQUIRE(rootObject->getValue().type() == ANARI_SURFACE); + REQUIRE(rootObject->getValue().getAsObjectIndex() == 0); + + auto *surfaceNode = + exportedTree.root().child("objectDB")->child("surface")->child(0); + REQUIRE(surfaceNode); + REQUIRE(surfaceNode->child("self")->getValue().type() == ANARI_SURFACE); + REQUIRE(surfaceNode->child("self")->getValue().getAsObjectIndex() + == 0); + } + + tsd::scene::Scene target; + auto existingGeometry = + target.createObject(tsd::scene::tokens::geometry::sphere); + existingGeometry->setName("preexisting_geometry"); + target.addLayer("keep_me"); + + auto imported = tsd::io::import_Surface(target, filename.c_str()); + + THEN("the import appends objects without creating layers") + { + REQUIRE(imported); + REQUIRE(target.numberOfLayers() == 1); + REQUIRE(target.layer("keep_me") != nullptr); + REQUIRE(target.numberOfObjects(ANARI_SURFACE) == 1); + REQUIRE(target.numberOfObjects(ANARI_GEOMETRY) == 2); + REQUIRE(target.numberOfObjects(ANARI_MATERIAL) == 2); + REQUIRE(target.numberOfObjects(ANARI_SAMPLER) == 1); + REQUIRE(target.numberOfObjects(ANARI_ARRAY) == 2); + } + + THEN("surface dependencies, metadata, data, and sharing round-trip") + { + REQUIRE(imported->name() == "root_surface"); + REQUIRE(imported->getMetadataValue("priority").getAs() == 9); + + auto *importedGeometry = imported->geometry(); + auto *importedMaterial = imported->material(); + REQUIRE(importedGeometry); + REQUIRE(importedMaterial); + REQUIRE(importedGeometry->name() == "mesh_geometry"); + REQUIRE(importedMaterial->name() == "sampled_material"); + + auto geometryMetadata = + imported->getMetadataValue("geometryRef"); + REQUIRE(geometryMetadata.holdsObject()); + REQUIRE(geometryMetadata.getAsObjectIndex() + == importedGeometry->index()); + + auto *positionParam = importedGeometry->parameter("vertex.position"); + REQUIRE(positionParam); + REQUIRE(!positionParam->isEnabled()); + REQUIRE(positionParam->description() == "positions"); + + auto *importedPositions = + importedGeometry->parameterValueAsObject( + "vertex.position"); + REQUIRE(importedPositions); + REQUIRE(importedPositions->name() == "positions"); + REQUIRE(importedPositions->getMetadataValue("stride").getAs() + == 12); + REQUIRE(importedPositions->size() == 6); + const auto *positionData = importedPositions->dataAs(); + REQUIRE(positionData[0] == 1.f); + REQUIRE(positionData[5] == 6.f); + + auto *importedSampler = + importedMaterial->parameterValueAsObject( + "color"); + REQUIRE(importedSampler); + auto *importedTexture = + importedSampler->parameterValueAsObject("image"); + REQUIRE(importedTexture); + REQUIRE(importedTexture->name() == "texture"); + REQUIRE(importedTexture->dataAs()[2] == 0.75f); + + auto samplerMetadata = importedMaterial->getMetadataValue("samplerRef"); + REQUIRE(samplerMetadata.holdsObject()); + REQUIRE(samplerMetadata.getAsObjectIndex() == importedSampler->index()); + } + } + + removeTestFile(filename); + } +} + +SCENARIO("tsd::io volume object serialization", "[Serialization]") +{ + GIVEN("A volume with a spatial field, transfer function arrays, and metadata") + { + tsd::scene::Scene source; + + auto fieldData = makeFloatArray(source, "field_data", {0.f, 1.f, 2.f, 3.f}); + auto colors = makeFloatArray(source, "tf_colors", {1.f, 0.f, 0.f, 1.f}); + auto opacity = makeFloatArray(source, "tf_opacity", {0.f, 1.f}); + + auto field = source.createObject( + tsd::scene::tokens::spatial_field::structuredRegular); + field->setName("density_field"); + field->setParameterObject("data", *fieldData); + field->setMetadataValue("sourceData", + tsd::core::Any(fieldData->type(), fieldData->index())); + + auto sampler = + source.createObject(tsd::scene::tokens::sampler::image1D); + sampler->setName("tf_sampler"); + sampler->setParameterObject("image", *colors); + + auto volume = source.createObject( + tsd::scene::tokens::volume::transferFunction1D); + volume->setName("root_volume"); + volume->removeAllParameters(); + volume->setParameterObject("value", *field); + volume->setParameterObject("color", *sampler); + volume->setParameterObject("opacity", *opacity); + volume->setMetadataValue("fieldRef", + tsd::core::Any(field->type(), field->index())); + + const auto filename = testFile("tsd_volume_object_roundtrip.tsd"); + removeTestFile(filename); + + WHEN("the volume is exported and imported into a non-empty scene") + { + REQUIRE(tsd::io::export_Object(filename.c_str(), *volume)); + + tsd::core::DataTree exportedTree; + REQUIRE(exportedTree.load(filename.c_str())); + REQUIRE(tsd::io::validate_VolumePayload(exportedTree.root()).accepted()); + + tsd::scene::Scene target; + target.createObject( + tsd::scene::tokens::spatial_field::structuredRegular); + + auto imported = tsd::io::import_Volume(target, filename.c_str()); + + THEN("the imported volume preserves field, arrays, metadata, and refs") + { + REQUIRE(imported); + REQUIRE(imported->name() == "root_volume"); + REQUIRE(target.numberOfObjects(ANARI_VOLUME) == 1); + REQUIRE(target.numberOfObjects(ANARI_SPATIAL_FIELD) == 2); + REQUIRE(target.numberOfObjects(ANARI_ARRAY) == 3); + + auto *importedField = + imported->parameterValueAsObject("value"); + REQUIRE(importedField); + REQUIRE(importedField->name() == "density_field"); + + auto fieldRef = imported->getMetadataValue("fieldRef"); + REQUIRE(fieldRef.holdsObject()); + REQUIRE(fieldRef.getAsObjectIndex() == importedField->index()); + + auto *importedData = + importedField->parameterValueAsObject("data"); + REQUIRE(importedData); + REQUIRE(importedData->dataAs()[3] == 3.f); + + auto sourceData = importedField->getMetadataValue("sourceData"); + REQUIRE(sourceData.holdsObject()); + REQUIRE(sourceData.getAsObjectIndex() == importedData->index()); + + auto *importedSampler = + imported->parameterValueAsObject("color"); + REQUIRE(importedSampler); + auto *importedColors = + importedSampler->parameterValueAsObject("image"); + REQUIRE(importedColors); + REQUIRE(importedColors->name() == "tf_colors"); + REQUIRE(importedColors->dataAs()[0] == 1.f); + + auto *importedOpacity = + imported->parameterValueAsObject("opacity"); + REQUIRE(importedOpacity); + REQUIRE(importedOpacity->name() == "tf_opacity"); + } + } + + removeTestFile(filename); + } +} + +SCENARIO("tsd::io object payload validation failures", "[Serialization]") +{ + GIVEN("A full scene payload") + { + tsd::scene::Scene scene; + tsd::core::DataTree tree; + tsd::io::save_Scene(scene, tree.root(), false); + const auto filename = testFile("tsd_full_scene_rejected_by_object_import.tsd"); + removeTestFile(filename); + REQUIRE(tree.save(filename.c_str())); + + THEN("object import validators reject it") + { + auto result = tsd::io::validate_ObjectPayload(tree.root()); + REQUIRE(!result.accepted()); + REQUIRE(result.status == tsd::io::PayloadValidationStatus::IncompatibleSchema); + + tsd::scene::Scene target; + const auto before = target.numberOfObjects(ANARI_MATERIAL); + REQUIRE(tsd::io::import_Object(target, filename.c_str()) == nullptr); + REQUIRE(target.numberOfObjects(ANARI_MATERIAL) == before); + } + + removeTestFile(filename); + } + + GIVEN("A surface object file") + { + tsd::scene::Scene source; + auto geometry = + source.createObject(tsd::scene::tokens::geometry::sphere); + auto material = + source.createObject(tsd::scene::tokens::material::matte); + auto surface = source.createSurface("surface", geometry, material); + + const auto filename = testFile("tsd_invalid_surface_object.tsd"); + removeTestFile(filename); + REQUIRE(tsd::io::export_Object(filename.c_str(), *surface)); + + tsd::core::DataTree tree; + REQUIRE(tree.load(filename.c_str())); + + WHEN("an extra unreferenced object is present") + { + auto &extra = + tree.root()["objectDB"]["geometry"].append(); + tsd::io::objectToNode(*geometry, extra); + extra["self"] = tsd::core::Any(ANARI_GEOMETRY, size_t(1)); + + THEN("validation rejects the payload") + { + auto result = tsd::io::validate_SurfacePayload(tree.root()); + REQUIRE(!result.accepted()); + REQUIRE(result.status == tsd::io::PayloadValidationStatus::IncompatibleSchema); + } + } + + removeTestFile(filename); + } + + GIVEN("A surface payload with a disallowed volume pool") + { + tsd::core::DataTree tree; + auto &root = tree.root(); + tsd::core::writeDataTreeMetadata(root, + {tsd::core::DATA_TREE_METADATA_ENVELOPE_VERSION, + "object", + std::string(tsd::io::schema::OBJECT_SURFACE), + 1}); + root["rootObject"] = tsd::core::Any(ANARI_SURFACE, size_t(0)); + auto &surfaceNode = root["objectDB"]["surface"].append(); + surfaceNode["name"] = "surface"; + surfaceNode["self"] = tsd::core::Any(ANARI_SURFACE, size_t(0)); + surfaceNode["subtype"] = ""; + auto &volumeNode = root["objectDB"]["volume"].append(); + volumeNode["name"] = "volume"; + volumeNode["self"] = tsd::core::Any(ANARI_VOLUME, size_t(0)); + volumeNode["subtype"] = tsd::scene::tokens::volume::transferFunction1D.c_str(); + + THEN("validation rejects the disallowed pool") + { + auto result = tsd::io::validate_SurfacePayload(root); + REQUIRE(!result.accepted()); + REQUIRE(result.status == tsd::io::PayloadValidationStatus::IncompatibleSchema); + } + } +} + +SCENARIO("tsd::io object export failures", "[Serialization]") +{ + GIVEN("An unsupported root object type") + { + tsd::scene::Scene scene; + auto geometry = + scene.createObject(tsd::scene::tokens::geometry::sphere); + + THEN("export fails") + { + REQUIRE_FALSE(tsd::io::export_Object( + testFile("tsd_unsupported_object.tsd").c_str(), *geometry)); + } + } + + GIVEN("A surface reaching a proxy array") + { + tsd::scene::Scene scene; + auto proxy = scene.createArrayProxy(ANARI_FLOAT32, 4); + auto geometry = + scene.createObject(tsd::scene::tokens::geometry::sphere); + geometry->setParameterObject("primitive.radius", *proxy); + auto material = + scene.createObject(tsd::scene::tokens::material::matte); + auto surface = scene.createSurface("surface", geometry, material); + + THEN("export fails because object files must be self-contained") + { + REQUIRE_FALSE(tsd::io::export_Object( + testFile("tsd_proxy_array_object.tsd").c_str(), *surface)); + } + } + + GIVEN("A surface reaching an object-typed array") + { + tsd::scene::Scene scene; + auto objectArray = scene.createArray(ANARI_SURFACE, 1); + auto geometry = + scene.createObject(tsd::scene::tokens::geometry::sphere); + geometry->setParameterObject("surface.ids", *objectArray); + auto material = + scene.createObject(tsd::scene::tokens::material::matte); + auto surface = scene.createSurface("surface", geometry, material); + + THEN("export fails because object-valued array data cannot be remapped") + { + REQUIRE_FALSE(tsd::io::export_Object( + testFile("tsd_object_typed_array_object.tsd").c_str(), *surface)); + } + } +}