From f7cd7e3410ced01fac0e0093bb69f7fa07469a38 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 1 May 2026 16:57:04 -0500 Subject: [PATCH 01/49] Add SciVis Studio model types --- tsd/apps/interactive/scivisStudio/Dataset.cpp | 56 +++++++++ tsd/apps/interactive/scivisStudio/Dataset.h | 70 +++++++++++ tsd/apps/interactive/scivisStudio/Project.cpp | 95 ++++++++++++++ tsd/apps/interactive/scivisStudio/Project.h | 48 ++++++++ tsd/apps/interactive/scivisStudio/Shot.cpp | 36 ++++++ tsd/apps/interactive/scivisStudio/Shot.h | 51 ++++++++ .../scivisStudio/ShotCameraRig.cpp | 116 ++++++++++++++++++ .../interactive/scivisStudio/ShotCameraRig.h | 49 ++++++++ 8 files changed, 521 insertions(+) create mode 100644 tsd/apps/interactive/scivisStudio/Dataset.cpp create mode 100644 tsd/apps/interactive/scivisStudio/Dataset.h create mode 100644 tsd/apps/interactive/scivisStudio/Project.cpp create mode 100644 tsd/apps/interactive/scivisStudio/Project.h create mode 100644 tsd/apps/interactive/scivisStudio/Shot.cpp create mode 100644 tsd/apps/interactive/scivisStudio/Shot.h create mode 100644 tsd/apps/interactive/scivisStudio/ShotCameraRig.cpp create mode 100644 tsd/apps/interactive/scivisStudio/ShotCameraRig.h diff --git a/tsd/apps/interactive/scivisStudio/Dataset.cpp b/tsd/apps/interactive/scivisStudio/Dataset.cpp new file mode 100644 index 000000000..95e70548b --- /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 { + +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 datasetSourceKindFromString(const std::string &s) +{ + if (s == "TimeSeries") + return DatasetSourceKind::TimeSeries; + if (s == "Live") + return DatasetSourceKind::Live; + return DatasetSourceKind::Static; +} + +DatasetStatus datasetStatusFromString(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 diff --git a/tsd/apps/interactive/scivisStudio/Dataset.h b/tsd/apps/interactive/scivisStudio/Dataset.h new file mode 100644 index 000000000..4b96fe539 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/Dataset.h @@ -0,0 +1,70 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/core/ObjectPool.hpp" + +#include + +#include +#include + +namespace tsd::scivis_studio { + +using DatasetID = std::string; +using ShotID = std::string; +using ColorMapID = 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 Dataset +{ + DatasetID id; + std::string name; + DatasetSourceKind sourceKind{DatasetSourceKind::Static}; + std::string importerType{"NONE"}; + DatasetSourceMetadata source; + DatasetStatus status{DatasetStatus::Missing}; + SceneNodeRef rootNode; +}; + +const char *toString(DatasetSourceKind kind); +const char *toString(DatasetStatus status); +DatasetSourceKind datasetSourceKindFromString(const std::string &s); +DatasetStatus datasetStatusFromString(const std::string &s); + +} // 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..465513dd8 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/Project.cpp @@ -0,0 +1,95 @@ +// 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; +} + +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); +} + +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; +} + +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 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..905a4147f --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/Project.h @@ -0,0 +1,48 @@ +// 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 Project +{ + std::string name{"Untitled"}; + std::filesystem::path projectDirectory; + std::vector datasets; + std::vector shots; + std::vector colorMaps; + ShotID activeShotId; + bool dirty{false}; + + bool isSaved() const; + void markDirty(); + void markClean(); +}; + +std::string makeGeneratedId(const char *prefix, size_t ordinal); +DatasetID nextDatasetId(const Project &project); +ShotID nextShotId(const Project &project); +ColorMapID nextColorMapId(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); +Shot *activeShot(Project &project); +const Shot *activeShot(const Project &project); + +} // 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..0e71c18ac --- /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 { + +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 diff --git a/tsd/apps/interactive/scivisStudio/Shot.h b/tsd/apps/interactive/scivisStudio/Shot.h new file mode 100644 index 000000000..45927d195 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/Shot.h @@ -0,0 +1,51 @@ +// 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; + 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; + SceneNodeRef lightGroup; + SceneObjectRef camera; + ShotCameraRig cameraRig; + ShotRenderSettings renderSettings; +}; + +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 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..fdaeb15b2 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/ShotCameraRig.cpp @@ -0,0 +1,116 @@ +// 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 { + +const char *toString(CameraInterpolation interpolation) +{ + switch (interpolation) { + case CameraInterpolation::Hold: + return "Hold"; + case CameraInterpolation::Linear: + return "Linear"; + } + return "Linear"; +} + +CameraInterpolation cameraInterpolationFromString(const std::string &s) +{ + if (s == "Hold") + return CameraInterpolation::Hold; + 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()); + 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)}; +} + +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); + + ManipulatorState out; + out.orbit = a.manipulator.orbit; + out.orbit.lookat = + lerpVec3(t, a.manipulator.orbit.lookat, b.manipulator.orbit.lookat); + out.orbit.azeldist = tsd::rendering::lerpAzElDist( + t, a.manipulator.orbit.azeldist, b.manipulator.orbit.azeldist); + out.orbit.fixedDist = + lerp(t, a.manipulator.orbit.fixedDist, b.manipulator.orbit.fixedDist); + out.orbit.upAxis = a.manipulator.orbit.upAxis; + return out; + } + + return keyframes.back().manipulator; +} + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/ShotCameraRig.h b/tsd/apps/interactive/scivisStudio/ShotCameraRig.h new file mode 100644 index 000000000..c41aba022 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/ShotCameraRig.h @@ -0,0 +1,49 @@ +// 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 +}; + +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; +}; + +const char *toString(CameraInterpolation interpolation); +CameraInterpolation cameraInterpolationFromString(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 tsd::scivis_studio From 25283ceafbd8b1bf9200e5a9cc9d4daf5029ecf8 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 1 May 2026 16:57:10 -0500 Subject: [PATCH 02/49] Add SciVis Studio serialization tests --- .../scivisStudio/ProjectSerialization.cpp | 295 ++++++++++++++++++ .../scivisStudio/ProjectSerialization.h | 32 ++ tsd/tests/CMakeLists.txt | 9 + tsd/tests/test_SciVisStudio.cpp | 117 +++++++ 4 files changed, 453 insertions(+) create mode 100644 tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp create mode 100644 tsd/apps/interactive/scivisStudio/ProjectSerialization.h create mode 100644 tsd/tests/test_SciVisStudio.cpp diff --git a/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp b/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp new file mode 100644 index 000000000..3b27df677 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp @@ -0,0 +1,295 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "ProjectSerialization.h" + +#include "tsd/io/serialization.hpp" + +#include + +namespace tsd::scivis_studio { + +static void sceneNodeRefToNode( + const SceneNodeRef &ref, tsd::core::DataNode &node) +{ + node["layerName"] = ref.layerName; + node["nodeIndex"] = static_cast(ref.nodeIndex); +} + +static SceneNodeRef nodeToSceneNodeRef(tsd::core::DataNode &node) +{ + SceneNodeRef ref; + if (auto *c = node.child("layerName")) + ref.layerName = c->getValueAs(); + if (auto *c = node.child("nodeIndex")) + ref.nodeIndex = static_cast(c->getValueAs()); + return ref; +} + +static void sceneObjectRefToNode( + const SceneObjectRef &ref, tsd::core::DataNode &node) +{ + node["type"] = static_cast(ref.type); + node["objectIndex"] = static_cast(ref.objectIndex); +} + +static SceneObjectRef nodeToSceneObjectRef(tsd::core::DataNode &node) +{ + SceneObjectRef ref; + if (auto *c = node.child("type")) + ref.type = static_cast(c->getValueAs()); + if (auto *c = node.child("objectIndex")) + ref.objectIndex = static_cast(c->getValueAs()); + return ref; +} + +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"] = 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 = cameraInterpolationFromString( + kf["interpolationToNext"].getValueOr("Linear")); + if (auto *manip = kf.child("manipulator")) + nodeToManipulatorState(*manip, keyframe.manipulator); + rig.keyframes.push_back(std::move(keyframe)); + }); + } + sortKeyframes(rig); +} + +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"] = toString(dataset.sourceKind); + d["importerType"] = dataset.importerType; + d["status"] = toString(dataset.status); + sceneNodeRefToNode(dataset.rootNode, d["rootNode"]); + + auto &source = d["source"]; + source["absolutePath"] = dataset.source.absolutePath; + source["projectRelativePath"] = dataset.source.projectRelativePath; + source["fileSize"] = dataset.source.fileSize; + source["modifiedTime"] = dataset.source.modifiedTime; + } + + 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; + sceneNodeRefToNode(shot.lightGroup, s["lightGroup"]); + sceneObjectRefToNode(shot.camera, s["camera"]); + 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["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 &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 = datasetSourceKindFromString( + d["sourceKind"].getValueOr("Static")); + dataset.importerType = d["importerType"].getValueOr("NONE"); + dataset.status = + datasetStatusFromString(d["status"].getValueOr("Missing")); + if (auto *rootNode = d.child("rootNode")) + dataset.rootNode = nodeToSceneNodeRef(*rootNode); + + if (auto *source = d.child("source")) { + dataset.source.absolutePath = + (*source)["absolutePath"].getValueOr(""); + dataset.source.projectRelativePath = + (*source)["projectRelativePath"].getValueOr(""); + dataset.source.fileSize = + (*source)["fileSize"].getValueOr(0); + dataset.source.modifiedTime = + (*source)["modifiedTime"].getValueOr(0); + } + 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); + if (auto *lightGroup = s.child("lightGroup")) + shot.lightGroup = nodeToSceneNodeRef(*lightGroup); + if (auto *camera = s.child("camera")) + shot.camera = nodeToSceneObjectRef(*camera); + 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.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 *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(); + const auto kind = root["projectKind"].getValueOr(""); + if (kind != PROJECT_KIND) { + result.error = "projectKind is not SciVisStudio"; + return result; + } + + const auto version = root["schemaVersion"].getValueOr(0); + if (version != SCHEMA_VERSION) { + result.error = "unsupported 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..30e6a78f3 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/ProjectSerialization.h @@ -0,0 +1,32 @@ +// 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 int SCHEMA_VERSION = 1; +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/tests/CMakeLists.txt b/tsd/tests/CMakeLists.txt index 7d71f2565..c2721d366 100644 --- a/tsd/tests/CMakeLists.txt +++ b/tsd/tests/CMakeLists.txt @@ -24,6 +24,9 @@ project_add_executable( test_Scene.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 @@ -32,6 +35,9 @@ PRIVATE 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]" ) @@ -49,3 +55,6 @@ 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::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_SciVisStudio.cpp b/tsd/tests/test_SciVisStudio.cpp new file mode 100644 index 000000000..c1f9c9333 --- /dev/null +++ b/tsd/tests/test_SciVisStudio.cpp @@ -0,0 +1,117 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "catch.hpp" + +#include "ProjectContext.h" +#include "ProjectSerialization.h" + +#include "tsd/app/Context.h" +#include "tsd/core/DataTree.hpp" + +#include + +using namespace tsd::scivis_studio; + +SCENARIO("SciVis Studio project model serialization", "[SciVisStudio]") +{ + GIVEN("A project with datasets, shots, 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}}); + + Shot shot; + shot.id = "shot_0001"; + shot.name = "Shot 1"; + shot.datasetBindings.push_back({"dataset_0001", true}); + shot.lightGroup = {"studio", 5}; + shot.camera = {ANARI_CAMERA, 2}; + 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::Hold; + shot.cameraRig.keyframes.push_back(keyframe); + project.activeShotId = shot.id; + project.shots.push_back(shot); + + tsd::core::DataTree tree; + projectToNode(project, tree.root()["scivisStudio"]); + + Project loaded; + REQUIRE(nodeToProject(tree.root()["scivisStudio"], loaded)); + + THEN("IDs and keyframes survive round trip") + { + REQUIRE(loaded.datasets.size() == 1); + REQUIRE(loaded.datasets.front().id == "dataset_0001"); + REQUIRE(loaded.shots.size() == 1); + REQUIRE(loaded.shots.front().id == "shot_0001"); + 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::Hold); + } + } +} + +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 project manifest") + { + tsd::core::DataTree tree; + tree.root()["projectKind"] = PROJECT_KIND; + tree.root()["schemaVersion"] = SCHEMA_VERSION; + REQUIRE(tree.save((root / PROJECT_MANIFEST_FILENAME).string().c_str())); + + THEN("Validation succeeds") + { + auto result = validateProjectRoot(root); + REQUIRE(result.ok); + } + } + + GIVEN("An invalid 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); + } + } + + 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.activeShotId == project.shots.front().id); + REQUIRE(project.dirty == false); + REQUIRE(appContext.tsd.scene.layer("studio") != nullptr); +} From 581eb89df23c6d3afc37a843cdb2cf6f519b71a8 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 1 May 2026 16:57:16 -0500 Subject: [PATCH 03/49] Add SciVis Studio project context --- .../scivisStudio/ProjectContext.cpp | 543 ++++++++++++++++++ .../interactive/scivisStudio/ProjectContext.h | 67 +++ 2 files changed, 610 insertions(+) create mode 100644 tsd/apps/interactive/scivisStudio/ProjectContext.cpp create mode 100644 tsd/apps/interactive/scivisStudio/ProjectContext.h diff --git a/tsd/apps/interactive/scivisStudio/ProjectContext.cpp b/tsd/apps/interactive/scivisStudio/ProjectContext.cpp new file mode 100644 index 000000000..35f44939b --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/ProjectContext.cpp @@ -0,0 +1,543 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "ProjectContext.h" + +#include "ProjectSerialization.h" + +#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 + +namespace tsd::scivis_studio { + +ProjectContext::ProjectContext(tsd::app::Context *ctx) : m_ctx(ctx) {} + +void ProjectContext::setAppContext(tsd::app::Context *ctx) +{ + m_ctx = ctx; +} + +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(); +} + +tsd::scene::LayerNodeRef ProjectContext::ensureChild( + tsd::scene::LayerNodeRef parent, const char *name) +{ + tsd::scene::LayerNodeRef found; + if (!parent) + return {}; + + auto child = parent->next(); + while (child && child != parent) { + if ((*child)->name() == name) + found = child; + child = child->sibling(); + } + + if (found) + 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"); +} + +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); +} + +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; + } + } +} + +void ProjectContext::createUnsavedProject() +{ + resetScene(); + + m_project = {}; + m_project.name = "Untitled"; + + auto datasetsRoot = ensureDatasetsRoot(); + auto shotsRoot = ensureShotsRoot(); + (void)datasetsRoot; + + Shot shot; + shot.id = 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 = + manipulatorStateFromManipulator(m_ctx->view.manipulator); + tsd::rendering::updateCameraObject(*camera, m_ctx->view.manipulator); + + auto shotRoot = ensureChild(shotsRoot, shot.id.c_str()); + auto lightsRoot = ensureChild(shotRoot, "lights"); + shot.lightGroup = refFor("studio", lightsRoot); + + auto light = m_ctx->tsd.scene.createObject( + tsd::scene::tokens::light::directional); + light->setName("mainLight"); + light->setParameter("direction", tsd::math::float2(0.f, 240.f)); + light->setParameter("irradiance", 1.f); + m_ctx->tsd.scene.insertChildObjectNode(lightsRoot, light, "mainLight"); + + m_project.shots.push_back(std::move(shot)); + m_project.activeShotId = m_project.shots.front().id; + m_project.markClean(); + applyActiveShot(); +} + +bool ProjectContext::addShot(const std::string &name) +{ + if (!m_ctx) + return false; + + Shot shot; + shot.id = 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 = + manipulatorStateFromManipulator(m_ctx->view.manipulator); + tsd::rendering::updateCameraObject(*camera, m_ctx->view.manipulator); + + auto shotRoot = ensureChild(ensureShotsRoot(), shot.id.c_str()); + auto lightsRoot = ensureChild(shotRoot, "lights"); + shot.lightGroup = refFor("studio", lightsRoot); + + auto light = m_ctx->tsd.scene.createObject( + tsd::scene::tokens::light::directional); + light->setName("mainLight"); + light->setParameter("direction", tsd::math::float2(0.f, 240.f)); + light->setParameter("irradiance", 1.f); + m_ctx->tsd.scene.insertChildObjectNode(lightsRoot, light, "mainLight"); + + m_project.activeShotId = shot.id; + m_project.shots.push_back(std::move(shot)); + m_project.markDirty(); + 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); + metadata.absolutePath = ec ? sourcePath.string() : 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; +} + +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 = 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) + setDatasetBinding(shot, record.id, &shot == 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; +} + +void ProjectContext::applyActiveShot() +{ + if (!m_ctx) + return; + + auto *shot = activeShot(m_project); + if (!shot) + return; + + for (auto &s : m_project.shots) { + if (auto node = resolve(s.lightGroup)) + (*node)->setEnabled(s.id == shot->id); + } + + for (const auto &dataset : m_project.datasets) { + bool enabled = false; + if (const auto *binding = findDatasetBinding(*shot, dataset.id)) + enabled = binding->enabled; + if (auto node = resolve(dataset.rootNode)) + (*node)->setEnabled(enabled); + } + + auto sampled = sampleCameraRig(shot->cameraRig, shot->currentFrame); + applyManipulatorState(m_ctx->view.manipulator, sampled); + + if (auto *obj = resolve(shot->camera)) { + auto *camera = static_cast(obj); + tsd::rendering::updateCameraObject(*camera, m_ctx->view.manipulator); + } +} + +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(); + root["projectKind"] = PROJECT_KIND; + root["schemaVersion"] = 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(); + if (auto *context = root.child("context")) + tsd::io::load_Scene(m_ctx->tsd.scene, *context, &m_ctx->tsd.animationMgr); + + loadedProject.projectDirectory = directory; + loadedProject.markClean(); + m_project = std::move(loadedProject); + markMissingDatasets(); + + 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::Static) + continue; + + if (!dataset.source.absolutePath.empty() + && !std::filesystem::exists(dataset.source.absolutePath)) + dataset.status = DatasetStatus::Missing; + } +} + +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..34516efa8 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/ProjectContext.h @@ -0,0 +1,67 @@ +// 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 + +namespace tsd::scivis_studio { + +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); + void applyActiveShot(); + + 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; + + private: + tsd::scene::LayerNodeRef ensureChild( + tsd::scene::LayerNodeRef parent, const char *name); + tsd::scene::LayerNodeRef ensureStudioRoot(); + tsd::scene::LayerNodeRef ensureDatasetsRoot(); + tsd::scene::LayerNodeRef ensureShotsRoot(); + void resetScene(); + void ensureRendererDefaults(Shot &shot); + void markMissingDatasets(); + + tsd::app::Context *m_ctx{nullptr}; + Project m_project; +}; + +const char *toString(tsd::io::ImporterType importerType); +tsd::io::ImporterType importerTypeFromString(const std::string &s); + +} // namespace tsd::scivis_studio From 3f8016e9434b7716c345aae9f7c0e07455f4cc5a Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 1 May 2026 16:57:23 -0500 Subject: [PATCH 04/49] Add SciVis Studio application shell --- tsd/apps/interactive/CMakeLists.txt | 1 + .../interactive/scivisStudio/Application.cpp | 437 ++++++++++++++++++ .../interactive/scivisStudio/Application.h | 89 ++++ .../interactive/scivisStudio/CMakeLists.txt | 44 ++ .../interactive/scivisStudio/RenderShot.cpp | 139 ++++++ .../interactive/scivisStudio/RenderShot.h | 20 + .../scivisStudio/modals/AddDatasetDialog.cpp | 105 +++++ .../scivisStudio/modals/AddDatasetDialog.h | 28 ++ .../modals/ConfirmDiscardDialog.cpp | 43 ++ .../modals/ConfirmDiscardDialog.h | 26 ++ .../modals/ProjectLocationDialog.cpp | 89 ++++ .../modals/ProjectLocationDialog.h | 40 ++ .../interactive/scivisStudio/scivisStudio.cpp | 11 + .../scivisStudio/windows/CameraRigEditor.cpp | 134 ++++++ .../scivisStudio/windows/CameraRigEditor.h | 24 + .../scivisStudio/windows/DatasetEditor.cpp | 54 +++ .../scivisStudio/windows/DatasetEditor.h | 24 + .../scivisStudio/windows/ProjectWindow.cpp | 45 ++ .../scivisStudio/windows/ProjectWindow.h | 23 + .../scivisStudio/windows/ShotEditor.cpp | 109 +++++ .../scivisStudio/windows/ShotEditor.h | 29 ++ 21 files changed, 1514 insertions(+) create mode 100644 tsd/apps/interactive/scivisStudio/Application.cpp create mode 100644 tsd/apps/interactive/scivisStudio/Application.h create mode 100644 tsd/apps/interactive/scivisStudio/CMakeLists.txt create mode 100644 tsd/apps/interactive/scivisStudio/RenderShot.cpp create mode 100644 tsd/apps/interactive/scivisStudio/RenderShot.h create mode 100644 tsd/apps/interactive/scivisStudio/modals/AddDatasetDialog.cpp create mode 100644 tsd/apps/interactive/scivisStudio/modals/AddDatasetDialog.h create mode 100644 tsd/apps/interactive/scivisStudio/modals/ConfirmDiscardDialog.cpp create mode 100644 tsd/apps/interactive/scivisStudio/modals/ConfirmDiscardDialog.h create mode 100644 tsd/apps/interactive/scivisStudio/modals/ProjectLocationDialog.cpp create mode 100644 tsd/apps/interactive/scivisStudio/modals/ProjectLocationDialog.h create mode 100644 tsd/apps/interactive/scivisStudio/scivisStudio.cpp create mode 100644 tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp create mode 100644 tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.h create mode 100644 tsd/apps/interactive/scivisStudio/windows/DatasetEditor.cpp create mode 100644 tsd/apps/interactive/scivisStudio/windows/DatasetEditor.h create mode 100644 tsd/apps/interactive/scivisStudio/windows/ProjectWindow.cpp create mode 100644 tsd/apps/interactive/scivisStudio/windows/ProjectWindow.h create mode 100644 tsd/apps/interactive/scivisStudio/windows/ShotEditor.cpp create mode 100644 tsd/apps/interactive/scivisStudio/windows/ShotEditor.h 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/scivisStudio/Application.cpp b/tsd/apps/interactive/scivisStudio/Application.cpp new file mode 100644 index 000000000..55d9a5f8d --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/Application.cpp @@ -0,0 +1,437 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "Application.h" + +#include "RenderShot.h" +#include "modals/AddDatasetDialog.h" +#include "modals/ConfirmDiscardDialog.h" +#include "modals/ProjectLocationDialog.h" +#include "windows/CameraRigEditor.h" +#include "windows/DatasetEditor.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 + +namespace tsd::scivis_studio { + +using TSDApplication = tsd::ui::imgui::Application; +namespace tsd_ui = tsd::ui::imgui; + +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; +} + +anari_viewer::WindowArray Application::setupWindows() +{ + auto windows = TSDApplication::setupWindows(); + + 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 *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(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_confirmDiscardDialog = std::make_unique(this); + m_addDatasetDialog = + std::make_unique(this, &m_projectContext); + + if (!m_initialProjectDirectory.empty()) { + if (!openProject(m_initialProjectDirectory)) + m_projectContext.createUnsavedProject(); + } else + m_projectContext.createUnsavedProject(); + + if (m_viewport) + m_viewport->setLibraryToDefault(); + + 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()); + return ok; +} + +bool Application::openProject(const std::filesystem::path &directory) +{ + 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()); + return false; + } + + loadWindowSettings(scratch.root()["windows"]); + loadLayout(layout); + loadApplicationSettings(scratch.root()); + return true; +} + +void Application::newProject() +{ + m_projectContext.createUnsavedProject(); +} + +void Application::closeProject() +{ + m_projectContext.createUnsavedProject(); +} + +void Application::requestDirtyAction(PendingDirtyAction action) +{ + if (!m_projectContext.project().dirty) { + m_pendingDirtyAction = action; + continueDirtyAction(); + return; + } + + m_pendingDirtyAction = action; + m_confirmDiscardDialog->configure( + [this]() { continueDirtyAction(); }, + [this]() { m_pendingDirtyAction = PendingDirtyAction::None; }); + m_confirmDiscardDialog->show(); +} + +void Application::continueDirtyAction() +{ + const auto action = m_pendingDirtyAction; + m_pendingDirtyAction = PendingDirtyAction::None; + + if (action == PendingDirtyAction::NewProject) + showProjectLocationDialogForNew(); + else if (action == PendingDirtyAction::OpenProject) + showProjectLocationDialogForOpen(); +} + +void Application::showAddDatasetDialog() +{ + m_addDatasetDialog->show(); +} + +void Application::showProjectLocationDialogForNew() +{ + m_projectLocationDialog->configure(ProjectLocationMode::NewProject, + [this](const std::filesystem::path &directory) { + newProject(); + saveProjectAs(directory); + }); + m_projectLocationDialog->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; + } + + showTaskModal( + [this]() { + RenderShotProgress progress; + renderActiveShotToFrames(m_projectContext, &progress); + }, + "Rendering Active Shot..."); +} + +void Application::tickShotPlayback(float deltaTime) +{ + auto *shot = activeShot(m_projectContext.project()); + if (!shot || !shot->playing || shot->fps <= 0.f) + return; + + m_playbackAccumulator += deltaTime; + const float frameDuration = 1.f / shot->fps; + if (m_playbackAccumulator < frameDuration) + return; + + int steps = static_cast(m_playbackAccumulator / frameDuration); + m_playbackAccumulator -= steps * frameDuration; + while (steps-- > 0 && shot->playing) { + ++shot->currentFrame; + if (shot->currentFrame >= shot->frameCount) { + if (shot->loop) + shot->currentFrame = 0; + else { + shot->currentFrame = std::max(0, shot->frameCount - 1); + shot->playing = false; + } + } + } + + m_projectContext.applyActiveShot(); +} + +void Application::uiFrameStart() +{ + const ImGuiIO &io = ImGui::GetIO(); + tickShotPlayback(io.DeltaTime); + + if (ImGui::BeginMainMenuBar()) { + uiMainMenuBar(); + ImGui::EndMainMenuBar(); + } + + bool modalActive = false; + if (m_taskModal && m_taskModal->visible()) { + m_taskModal->renderUI(); + modalActive = true; + } + + if (m_projectLocationDialog && m_projectLocationDialog->visible()) { + m_projectLocationDialog->renderUI(); + modalActive = true; + } + + if (m_confirmDiscardDialog && m_confirmDiscardDialog->visible()) { + m_confirmDiscardDialog->renderUI(); + modalActive = true; + } + + if (m_addDatasetDialog && m_addDatasetDialog->visible()) { + m_addDatasetDialog->renderUI(); + modalActive = true; + } + + if (!io.WantTextInput && ImGui::IsKeyPressed(ImGuiKey_Space)) { + if (auto *shot = activeShot(m_projectContext.project())) { + shot->playing = !shot->playing; + m_playbackAccumulator = 0.f; + } + } + + if (ImGui::IsKeyChordPressed(ImGuiMod_Ctrl | ImGuiKey_S)) + saveProject(); + + if (!modalActive && ImGui::IsKeyChordPressed(ImGuiKey_Escape)) + appContext()->clearSelected(); +} + +void Application::uiMainMenuBar() +{ + if (ImGui::BeginMenu("Project")) { + if (ImGui::MenuItem("New Project...")) + requestDirtyAction(PendingDirtyAction::NewProject); + if (ImGui::MenuItem("Open Project...")) + requestDirtyAction(PendingDirtyAction::OpenProject); + if (ImGui::MenuItem("Save Project", "Ctrl+S")) + saveProject(); + if (ImGui::MenuItem("Save Project As...")) + showProjectLocationDialogForSaveAs(); + if (ImGui::MenuItem("Close Project")) + requestDirtyAction(PendingDirtyAction::NewProject); + ImGui::Separator(); + if (ImGui::MenuItem("Quit")) + std::exit(0); + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("Studio")) { + if (ImGui::MenuItem("Add Dataset...")) + showAddDatasetDialog(); + 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("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 R"layout( +[Window][MainDockSpace] +Pos=0,56 +Size=1920,1024 +Collapsed=0 + +[Window][Project] +Pos=0,56 +Size=360,310 +Collapsed=0 +DockId=0x00000001,0 + +[Window][Dataset Editor] +Pos=0,368 +Size=360,280 +Collapsed=0 +DockId=0x00000002,0 + +[Window][Shot Editor] +Pos=0,650 +Size=360,430 +Collapsed=0 +DockId=0x00000003,0 + +[Window][Camera Rig] +Pos=362,760 +Size=1038,320 +Collapsed=0 +DockId=0x00000004,0 + +[Window][Viewport] +Pos=362,56 +Size=1038,702 +Collapsed=0 +DockId=0x00000005,0 + +[Window][Object Editor] +Pos=1402,56 +Size=518,702 +Collapsed=0 +DockId=0x00000006,0 + +[Window][Log] +Pos=1402,760 +Size=518,320 +Collapsed=0 +DockId=0x00000007,0 + +[Window][Layers] +Pos=60,60 +Size=420,600 +Collapsed=0 + +[Window][TF Editor] +Pos=80,80 +Size=480,500 +Collapsed=0 +)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..24a8d61a6 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/Application.h @@ -0,0 +1,89 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "ProjectContext.h" + +#include "tsd/ui/imgui/Application.h" + +#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 AddDatasetDialog; +struct CameraRigEditor; +struct ConfirmDiscardDialog; +struct DatasetEditor; +struct ProjectLocationDialog; +struct ProjectWindow; +struct ShotEditor; + +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 showAddDatasetDialog(); + void showProjectLocationDialogForNew(); + void showProjectLocationDialogForOpen(); + void showProjectLocationDialogForSaveAs(); + void renderActiveShot(); + + protected: + anari_viewer::WindowArray setupWindows() override; + void uiFrameStart() override; + void teardown() override; + void uiMainMenuBar() override; + const char *getDefaultLayout() const override; + + private: + enum class PendingDirtyAction + { + None, + NewProject, + OpenProject + }; + + bool saveProject(); + bool saveProjectAs(const std::filesystem::path &directory); + bool openProject(const std::filesystem::path &directory); + void newProject(); + void closeProject(); + void tickShotPlayback(float deltaTime); + 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 continueDirtyAction(); + + ProjectContext m_projectContext; + std::filesystem::path m_initialProjectDirectory; + PendingDirtyAction m_pendingDirtyAction{PendingDirtyAction::None}; + float m_playbackAccumulator{0.f}; + + 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_confirmDiscardDialog; + std::unique_ptr m_addDatasetDialog; +}; + +} // 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..75ce34095 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/CMakeLists.txt @@ -0,0 +1,44 @@ +## 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 + 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 +) + +add_executable(scivisStudio + Application.cpp + RenderShot.cpp + modals/AddDatasetDialog.cpp + modals/ConfirmDiscardDialog.cpp + modals/ProjectLocationDialog.cpp + scivisStudio.cpp + windows/CameraRigEditor.cpp + windows/DatasetEditor.cpp + windows/ProjectWindow.cpp + windows/ShotEditor.cpp +) + +target_link_libraries(scivisStudio +PRIVATE + tsd_scivis_studio_model + tsd_ui_imgui +) diff --git a/tsd/apps/interactive/scivisStudio/RenderShot.cpp b/tsd/apps/interactive/scivisStudio/RenderShot.cpp new file mode 100644 index 000000000..2446428a3 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/RenderShot.cpp @@ -0,0 +1,139 @@ +// 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 { + +bool renderActiveShotToFrames( + ProjectContext &projectContext, RenderShotProgress *progress) +{ + auto *ctx = projectContext.appContext(); + auto *shot = 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.resolve(shot->camera); + 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 subtype = shot->renderSettings.rendererSubtype.empty() + ? std::string("default") + : shot->renderSettings.rendererSubtype; + + auto library = + anari::loadLibrary(libName.c_str(), tsd::app::anariStatusFunc, nullptr); + if (!library) { + tsd::core::logError( + "[SciVisStudio] Failed to load ANARI library '%s'", libName.c_str()); + return false; + } + + auto device = anari::newDevice(library, "default"); + anari::unloadLibrary(library); + if (!device) { + tsd::core::logError( + "[SciVisStudio] Failed to create ANARI device '%s'", libName.c_str()); + return false; + } + anari::commitParameters(device, device); + + auto *renderIndex = + ctx->tsd.scene.updateDelegate() + .emplace( + ctx->tsd.scene, libName, device); + renderIndex->populate(); + + auto renderer = anari::newObject(device, subtype.c_str()); + anari::commitParameters(device, renderer); + + 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 int totalFrames = std::max(1, shot->frameCount); + const auto prefix = shot->renderSettings.outputFilePrefix.empty() + ? shot->id + : shot->renderSettings.outputFilePrefix; + + tsd::core::logStatus("[SciVisStudio] Rendering %d frames to '%s'", + totalFrames, + outputDirectory.string().c_str()); + + for (int frame = 0; frame < totalFrames; ++frame) { + if (progress && progress->onFrame && !progress->onFrame(frame, totalFrames)) + break; + + shot->currentFrame = frame; + projectContext.applyActiveShot(); + + 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; + projectContext.applyActiveShot(); + + ctx->tsd.scene.updateDelegate().erase(renderIndex); + anari::release(device, renderer); + anari::release(device, device); + + return true; +} + +} // 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/modals/AddDatasetDialog.cpp b/tsd/apps/interactive/scivisStudio/modals/AddDatasetDialog.cpp new file mode 100644 index 000000000..9d741c10e --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/modals/AddDatasetDialog.cpp @@ -0,0 +1,105 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "AddDatasetDialog.h" + +#include "tsd/core/Logging.hpp" +#include "tsd/ui/imgui/Application.h" + +#include "imgui.h" + +#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}, +}}; + +} // namespace + +AddDatasetDialog::AddDatasetDialog( + tsd::ui::imgui::Application *app, ProjectContext *projectContext) + : Modal(app, "Add Dataset"), m_projectContext(projectContext) +{} + +AddDatasetDialog::~AddDatasetDialog() = default; + +void AddDatasetDialog::buildUI() +{ + ImGui::InputText("Name", m_name.data(), m_name.size()); + 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/AddDatasetDialog.h b/tsd/apps/interactive/scivisStudio/modals/AddDatasetDialog.h new file mode 100644 index 000000000..3004447cf --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/modals/AddDatasetDialog.h @@ -0,0 +1,28 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "ProjectContext.h" +#include "tsd/ui/imgui/modals/Modal.h" + +#include + +namespace tsd::scivis_studio { + +struct AddDatasetDialog : public tsd::ui::imgui::Modal +{ + AddDatasetDialog( + tsd::ui::imgui::Application *app, ProjectContext *projectContext); + ~AddDatasetDialog() override; + + private: + void buildUI() override; + + ProjectContext *m_projectContext{nullptr}; + std::array m_name{}; + std::array m_sourcePath{}; + int m_selectedImporter{0}; +}; + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/modals/ConfirmDiscardDialog.cpp b/tsd/apps/interactive/scivisStudio/modals/ConfirmDiscardDialog.cpp new file mode 100644 index 000000000..fd21b35db --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/modals/ConfirmDiscardDialog.cpp @@ -0,0 +1,43 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "ConfirmDiscardDialog.h" + +#include "imgui.h" + +namespace tsd::scivis_studio { + +ConfirmDiscardDialog::ConfirmDiscardDialog(tsd::ui::imgui::Application *app) + : Modal(app, "Discard Unsaved Changes") +{} + +ConfirmDiscardDialog::~ConfirmDiscardDialog() = default; + +void ConfirmDiscardDialog::configure( + std::function onDiscard, std::function onCancel) +{ + m_onDiscard = std::move(onDiscard); + m_onCancel = std::move(onCancel); +} + +void ConfirmDiscardDialog::buildUI() +{ + ImGui::TextUnformatted("The current project has unsaved changes."); + ImGui::Spacing(); + + if (ImGui::Button("Cancel")) { + hide(); + if (m_onCancel) + m_onCancel(); + } + + ImGui::SameLine(); + + if (ImGui::Button("Discard and Continue")) { + hide(); + if (m_onDiscard) + m_onDiscard(); + } +} + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/modals/ConfirmDiscardDialog.h b/tsd/apps/interactive/scivisStudio/modals/ConfirmDiscardDialog.h new file mode 100644 index 000000000..057a839aa --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/modals/ConfirmDiscardDialog.h @@ -0,0 +1,26 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/ui/imgui/modals/Modal.h" + +#include + +namespace tsd::scivis_studio { + +struct ConfirmDiscardDialog : public tsd::ui::imgui::Modal +{ + explicit ConfirmDiscardDialog(tsd::ui::imgui::Application *app); + ~ConfirmDiscardDialog() override; + + void configure(std::function onDiscard, std::function onCancel); + + private: + void buildUI() override; + + std::function m_onDiscard; + std::function m_onCancel; +}; + +} // 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..957c53937 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/modals/ProjectLocationDialog.cpp @@ -0,0 +1,89 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "ProjectLocationDialog.h" + +#include "ProjectSerialization.h" + +#include "imgui.h" + +namespace tsd::scivis_studio { + +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::NewProject) { + title = "New Project"; + button = "Create"; + } else if (m_mode == ProjectLocationMode::SaveProjectAs) { + title = "Save Project As"; + button = "Save"; + } + + ImGui::TextUnformatted(title); + 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..146629a31 --- /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 +{ + NewProject, + 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_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/windows/CameraRigEditor.cpp b/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp new file mode 100644 index 000000000..5bf3147e4 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp @@ -0,0 +1,134 @@ +// 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 "imgui.h" + +#include + +namespace tsd::scivis_studio { + +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 = activeShot(project); + if (!shot) { + ImGui::TextDisabled("No active shot"); + return; + } + + auto *ctx = m_projectContext->appContext(); + auto &rig = shot->cameraRig; + + if (ImGui::Button("Set Rig View From Viewport")) { + rig.current = manipulatorStateFromManipulator(ctx->view.manipulator); + project.markDirty(); + } + + ImGui::SameLine(); + + if (ImGui::Button("Capture Keyframe At Current Frame")) { + CameraKeyframe keyframe; + keyframe.frame = shot->currentFrame; + keyframe.name = "Frame " + std::to_string(shot->currentFrame); + keyframe.manipulator = + manipulatorStateFromManipulator(ctx->view.manipulator); + rig.keyframes.push_back(std::move(keyframe)); + sortKeyframes(rig); + m_selectedKeyframe = static_cast(rig.keyframes.size()) - 1; + project.markDirty(); + } + + 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 Selected From Viewport")) { + rig.keyframes[m_selectedKeyframe].manipulator = + manipulatorStateFromManipulator(ctx->view.manipulator); + project.markDirty(); + } + ImGui::SameLine(); + if (ImGui::Button("Jump Viewport To Keyframe")) { + shot->currentFrame = rig.keyframes[m_selectedKeyframe].frame; + m_projectContext->applyActiveShot(); + } + ImGui::SameLine(); + if (ImGui::Button("Delete Keyframe")) { + rig.keyframes.erase(rig.keyframes.begin() + m_selectedKeyframe); + m_selectedKeyframe = -1; + project.markDirty(); + } + ImGui::EndDisabled(); + + if (ImGui::BeginTable("keyframes", 4, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { + 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(); + ImGui::TableNextColumn(); + if (ImGui::Selectable("##select", m_selectedKeyframe == i, + ImGuiSelectableFlags_SpanAllColumns)) + m_selectedKeyframe = i; + ImGui::SameLine(); + if (ImGui::InputInt("##frame", &keyframe.frame)) { + 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 = + keyframe.interpolationToNext == CameraInterpolation::Hold ? 0 : 1; + const char *items[] = {"Hold", "Linear"}; + if (ImGui::Combo("##interp", &interpolation, items, 2)) { + keyframe.interpolationToNext = + interpolation == 0 ? CameraInterpolation::Hold + : CameraInterpolation::Linear; + 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(); + } +} + +} // 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..739d4b2cd --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/windows/DatasetEditor.cpp @@ -0,0 +1,54 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "DatasetEditor.h" + +#include "imgui.h" + +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", toString(dataset.status)); + ImGui::Text("Source kind: %s", toString(dataset.sourceKind)); + ImGui::Text("Importer: %s", dataset.importerType.c_str()); + ImGui::TextWrapped("Path: %s", dataset.source.absolutePath.c_str()); + 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/ProjectWindow.cpp b/tsd/apps/interactive/scivisStudio/windows/ProjectWindow.cpp new file mode 100644 index 000000000..9631b4dd3 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/windows/ProjectWindow.cpp @@ -0,0 +1,45 @@ +// 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(), 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->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..594772e04 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/windows/ShotEditor.cpp @@ -0,0 +1,109 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "ShotEditor.h" + +#include "imgui.h" + +#include +#include +#include + +namespace tsd::scivis_studio { + +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() +{ + if (!m_projectContext) + return; + + auto &project = m_projectContext->project(); + auto *shot = activeShot(project); + if (!shot) { + ImGui::TextDisabled("No active shot"); + return; + } + + if (inputText("Name", shot->name)) + project.markDirty(); + + bool changed = false; + changed |= ImGui::InputInt("Current frame", &shot->currentFrame); + changed |= ImGui::InputInt("Frame count", &shot->frameCount); + changed |= ImGui::InputFloat("FPS", &shot->fps); + 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); + + if (ImGui::Button(shot->playing ? "Stop" : "Play")) + shot->playing = !shot->playing; + ImGui::SameLine(); + if (ImGui::Checkbox("Loop", &shot->loop)) + project.markDirty(); + + if (changed) { + project.markDirty(); + m_projectContext->applyActiveShot(); + } + + 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(); + } + if (inputText("Renderer library", shot->renderSettings.rendererLibrary)) + project.markDirty(); + if (inputText("Renderer subtype", shot->renderSettings.rendererSubtype)) + project.markDirty(); + if (inputText("Output prefix", shot->renderSettings.outputFilePrefix)) + project.markDirty(); + + 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 = findDatasetBinding(*shot, dataset.id)) + enabled = binding->enabled; + if (ImGui::Checkbox(dataset.name.c_str(), &enabled)) { + 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..6f54186ba --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/windows/ShotEditor.h @@ -0,0 +1,29 @@ +// 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 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); + + ProjectContext *m_projectContext{nullptr}; + std::function m_onRender; +}; + +} // namespace tsd::scivis_studio From 9999299e3a4fb646c3020051a494010997c07830 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 1 May 2026 19:57:02 -0500 Subject: [PATCH 05/49] Add SciVis Studio layout print menu item --- tsd/apps/interactive/scivisStudio/Application.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tsd/apps/interactive/scivisStudio/Application.cpp b/tsd/apps/interactive/scivisStudio/Application.cpp index 55d9a5f8d..5bcc36136 100644 --- a/tsd/apps/interactive/scivisStudio/Application.cpp +++ b/tsd/apps/interactive/scivisStudio/Application.cpp @@ -22,6 +22,7 @@ #include "imgui.h" #include +#include namespace tsd::scivis_studio { @@ -361,6 +362,8 @@ void Application::uiMainMenuBar() ImGui::PopID(); } ImGui::Separator(); + if (ImGui::MenuItem("Print Layout")) + std::printf("%s\n", ImGui::SaveIniSettingsToMemory()); if (ImGui::MenuItem("Reset Layout")) ImGui::LoadIniSettingsFromMemory(getDefaultLayout()); ImGui::EndMenu(); From 9ff5a2b9e0cb5746a93bf6462aae974a49c4ef17 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 1 May 2026 20:01:31 -0500 Subject: [PATCH 06/49] Use SciVis Studio default layout file --- .../interactive/scivisStudio/Application.cpp | 60 +-------------- .../interactive/scivisStudio/CMakeLists.txt | 17 ++++ .../scivisStudio/DefaultLayout.h.in | 10 +++ .../scivisStudio/default_ui_layout.txt | 77 +++++++++++++++++++ 4 files changed, 106 insertions(+), 58 deletions(-) create mode 100644 tsd/apps/interactive/scivisStudio/DefaultLayout.h.in create mode 100644 tsd/apps/interactive/scivisStudio/default_ui_layout.txt diff --git a/tsd/apps/interactive/scivisStudio/Application.cpp b/tsd/apps/interactive/scivisStudio/Application.cpp index 5bcc36136..a6b89de5c 100644 --- a/tsd/apps/interactive/scivisStudio/Application.cpp +++ b/tsd/apps/interactive/scivisStudio/Application.cpp @@ -3,6 +3,7 @@ #include "Application.h" +#include "DefaultLayout.h" #include "RenderShot.h" #include "modals/AddDatasetDialog.h" #include "modals/ConfirmDiscardDialog.h" @@ -377,64 +378,7 @@ void Application::uiMainMenuBar() const char *Application::getDefaultLayout() const { - return R"layout( -[Window][MainDockSpace] -Pos=0,56 -Size=1920,1024 -Collapsed=0 - -[Window][Project] -Pos=0,56 -Size=360,310 -Collapsed=0 -DockId=0x00000001,0 - -[Window][Dataset Editor] -Pos=0,368 -Size=360,280 -Collapsed=0 -DockId=0x00000002,0 - -[Window][Shot Editor] -Pos=0,650 -Size=360,430 -Collapsed=0 -DockId=0x00000003,0 - -[Window][Camera Rig] -Pos=362,760 -Size=1038,320 -Collapsed=0 -DockId=0x00000004,0 - -[Window][Viewport] -Pos=362,56 -Size=1038,702 -Collapsed=0 -DockId=0x00000005,0 - -[Window][Object Editor] -Pos=1402,56 -Size=518,702 -Collapsed=0 -DockId=0x00000006,0 - -[Window][Log] -Pos=1402,760 -Size=518,320 -Collapsed=0 -DockId=0x00000007,0 - -[Window][Layers] -Pos=60,60 -Size=420,600 -Collapsed=0 - -[Window][TF Editor] -Pos=80,80 -Size=480,500 -Collapsed=0 -)layout"; + return DEFAULT_LAYOUT; } } // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/CMakeLists.txt b/tsd/apps/interactive/scivisStudio/CMakeLists.txt index 75ce34095..33d7faec7 100644 --- a/tsd/apps/interactive/scivisStudio/CMakeLists.txt +++ b/tsd/apps/interactive/scivisStudio/CMakeLists.txt @@ -24,6 +24,18 @@ PUBLIC tsd_scene ) +set(SCIVIS_STUDIO_DEFAULT_LAYOUT_FILE + "${CMAKE_CURRENT_LIST_DIR}/default_ui_layout.txt") + +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 RenderShot.cpp @@ -37,6 +49,11 @@ add_executable(scivisStudio windows/ShotEditor.cpp ) +target_include_directories(scivisStudio +PRIVATE + "${CMAKE_CURRENT_BINARY_DIR}" +) + target_link_libraries(scivisStudio PRIVATE tsd_scivis_studio_model diff --git a/tsd/apps/interactive/scivisStudio/DefaultLayout.h.in b/tsd/apps/interactive/scivisStudio/DefaultLayout.h.in new file mode 100644 index 000000000..92c935da7 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/DefaultLayout.h.in @@ -0,0 +1,10 @@ +// 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"; + +} // 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..133df0f78 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/default_ui_layout.txt @@ -0,0 +1,77 @@ +[Window][MainDockSpace] +Pos=0,42 +Size=3840,1980 +Collapsed=0 + +[Window][Project] +Pos=0,42 +Size=872,577 +Collapsed=0 +DockId=0x00000002,0 + +[Window][Dataset Editor] +Pos=0,1453 +Size=872,569 +Collapsed=0 +DockId=0x0000000A,0 + +[Window][Shot Editor] +Pos=0,621 +Size=872,830 +Collapsed=0 +DockId=0x0000000B,0 + +[Window][Camera Rig] +Pos=874,1452 +Size=2966,570 +Collapsed=0 +DockId=0x00000008,1 + +[Window][Viewport] +Pos=874,42 +Size=2966,1408 +Collapsed=0 +DockId=0x00000005,0 + +[Window][Object Editor] +Pos=0,1453 +Size=872,569 +Collapsed=0 +DockId=0x0000000A,1 + +[Window][Log] +Pos=874,1452 +Size=2966,570 +Collapsed=0 +DockId=0x00000008,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=1672,927 +Size=496,168 +Collapsed=0 + +[Docking][Data] +DockSpace ID=0x80F5B4C5 Window=0x079D3A04 Pos=0,42 Size=3840,1980 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=0x3215D859 + DockNode ID=0x00000009 Parent=0x80F5B4C5 SizeRef=2966,1980 Split=Y + DockNode ID=0x00000005 Parent=0x00000009 SizeRef=3840,1408 CentralNode=1 Selected=0xC450F867 + DockNode ID=0x00000008 Parent=0x00000009 SizeRef=3840,570 Selected=0x4192BA76 From 3717885e051f4da466c8dfc7d7d41b2c695c01be Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 1 May 2026 20:06:23 -0500 Subject: [PATCH 07/49] Save SciVis Studio default layout from menu --- .../interactive/scivisStudio/Application.cpp | 32 +++++++++++++++++-- .../interactive/scivisStudio/Application.h | 1 + .../scivisStudio/DefaultLayout.h.in | 2 ++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/tsd/apps/interactive/scivisStudio/Application.cpp b/tsd/apps/interactive/scivisStudio/Application.cpp index a6b89de5c..e19297ea8 100644 --- a/tsd/apps/interactive/scivisStudio/Application.cpp +++ b/tsd/apps/interactive/scivisStudio/Application.cpp @@ -23,7 +23,7 @@ #include "imgui.h" #include -#include +#include namespace tsd::scivis_studio { @@ -282,6 +282,32 @@ void Application::tickShotPlayback(float deltaTime) m_projectContext.applyActiveShot(); } +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(); @@ -363,8 +389,8 @@ void Application::uiMainMenuBar() ImGui::PopID(); } ImGui::Separator(); - if (ImGui::MenuItem("Print Layout")) - std::printf("%s\n", ImGui::SaveIniSettingsToMemory()); + if (ImGui::MenuItem("Save Default Layout File")) + saveDefaultLayoutFile(); if (ImGui::MenuItem("Reset Layout")) ImGui::LoadIniSettingsFromMemory(getDefaultLayout()); ImGui::EndMenu(); diff --git a/tsd/apps/interactive/scivisStudio/Application.h b/tsd/apps/interactive/scivisStudio/Application.h index 24a8d61a6..176e81ae2 100644 --- a/tsd/apps/interactive/scivisStudio/Application.h +++ b/tsd/apps/interactive/scivisStudio/Application.h @@ -65,6 +65,7 @@ class Application : public tsd::ui::imgui::Application void newProject(); void closeProject(); void tickShotPlayback(float deltaTime); + void saveDefaultLayoutFile() const; void saveWindowSettings(tsd::core::DataNode &node); void loadWindowSettings(tsd::core::DataNode &node); std::string saveLayout() const; diff --git a/tsd/apps/interactive/scivisStudio/DefaultLayout.h.in b/tsd/apps/interactive/scivisStudio/DefaultLayout.h.in index 92c935da7..31065e63e 100644 --- a/tsd/apps/interactive/scivisStudio/DefaultLayout.h.in +++ b/tsd/apps/interactive/scivisStudio/DefaultLayout.h.in @@ -6,5 +6,7 @@ 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 From 04c6d4539872aab7b1946cd5916f8c1bcc8703b3 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 1 May 2026 20:12:12 -0500 Subject: [PATCH 08/49] Add dataset source file browser --- .../scivisStudio/modals/AddDatasetDialog.cpp | 19 +++++++++++++++++++ .../scivisStudio/modals/AddDatasetDialog.h | 2 ++ 2 files changed, 21 insertions(+) diff --git a/tsd/apps/interactive/scivisStudio/modals/AddDatasetDialog.cpp b/tsd/apps/interactive/scivisStudio/modals/AddDatasetDialog.cpp index 9d741c10e..bbb1a4e50 100644 --- a/tsd/apps/interactive/scivisStudio/modals/AddDatasetDialog.cpp +++ b/tsd/apps/interactive/scivisStudio/modals/AddDatasetDialog.cpp @@ -9,6 +9,7 @@ #include "imgui.h" #include +#include #include namespace tsd::scivis_studio { @@ -50,6 +51,13 @@ constexpr std::array IMPORTERS = {{ {"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 AddDatasetDialog::AddDatasetDialog( @@ -62,6 +70,17 @@ AddDatasetDialog::~AddDatasetDialog() = default; void AddDatasetDialog::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); + } + ImGui::SameLine(); ImGui::InputText("Source Path", m_sourcePath.data(), m_sourcePath.size()); const char *preview = IMPORTERS[m_selectedImporter].name; diff --git a/tsd/apps/interactive/scivisStudio/modals/AddDatasetDialog.h b/tsd/apps/interactive/scivisStudio/modals/AddDatasetDialog.h index 3004447cf..4e9943045 100644 --- a/tsd/apps/interactive/scivisStudio/modals/AddDatasetDialog.h +++ b/tsd/apps/interactive/scivisStudio/modals/AddDatasetDialog.h @@ -7,6 +7,7 @@ #include "tsd/ui/imgui/modals/Modal.h" #include +#include namespace tsd::scivis_studio { @@ -22,6 +23,7 @@ struct AddDatasetDialog : public tsd::ui::imgui::Modal ProjectContext *m_projectContext{nullptr}; std::array m_name{}; std::array m_sourcePath{}; + std::string m_browsedSourcePath; int m_selectedImporter{0}; }; From f3539b7581490686869c2c7cdedcdefe3ce1d32a Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 1 May 2026 20:28:57 -0500 Subject: [PATCH 09/49] update default layout --- .../scivisStudio/default_ui_layout.txt | 42 +++++++++++++++---- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/tsd/apps/interactive/scivisStudio/default_ui_layout.txt b/tsd/apps/interactive/scivisStudio/default_ui_layout.txt index 133df0f78..cab8308df 100644 --- a/tsd/apps/interactive/scivisStudio/default_ui_layout.txt +++ b/tsd/apps/interactive/scivisStudio/default_ui_layout.txt @@ -23,9 +23,9 @@ DockId=0x0000000B,0 [Window][Camera Rig] Pos=874,1452 -Size=2966,570 +Size=1485,570 Collapsed=0 -DockId=0x00000008,1 +DockId=0x00000003,0 [Window][Viewport] Pos=874,42 @@ -40,10 +40,10 @@ Collapsed=0 DockId=0x0000000A,1 [Window][Log] -Pos=874,1452 -Size=2966,570 +Pos=2361,1452 +Size=1479,570 Collapsed=0 -DockId=0x00000008,0 +DockId=0x00000004,0 [Window][Layers] Pos=60,60 @@ -65,13 +65,41 @@ Pos=1672,927 Size=496,168 Collapsed=0 +[Window][Add 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,42 Size=3840,1980 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=0x3215D859 + 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,1408 CentralNode=1 Selected=0xC450F867 - DockNode ID=0x00000008 Parent=0x00000009 SizeRef=3840,570 Selected=0x4192BA76 + DockNode ID=0x00000008 Parent=0x00000009 SizeRef=3840,570 Split=X Selected=0x4192BA76 + DockNode ID=0x00000003 Parent=0x00000008 SizeRef=1485,570 Selected=0x4192BA76 + DockNode ID=0x00000004 Parent=0x00000008 SizeRef=1479,570 Selected=0x139FDA3F + From ba8dfa904d7b6fcb065ec29ecb3260ab738371f7 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 1 May 2026 20:32:49 -0500 Subject: [PATCH 10/49] Add project directory browser --- .../scivisStudio/modals/AddDatasetDialog.cpp | 3 ++- .../modals/ProjectLocationDialog.cpp | 27 +++++++++++++++++++ .../modals/ProjectLocationDialog.h | 1 + tsd/src/tsd/ui/imgui/Application.cpp | 15 +++++++++-- tsd/src/tsd/ui/imgui/Application.h | 11 +++++++- 5 files changed, 53 insertions(+), 4 deletions(-) diff --git a/tsd/apps/interactive/scivisStudio/modals/AddDatasetDialog.cpp b/tsd/apps/interactive/scivisStudio/modals/AddDatasetDialog.cpp index bbb1a4e50..b6365b6f2 100644 --- a/tsd/apps/interactive/scivisStudio/modals/AddDatasetDialog.cpp +++ b/tsd/apps/interactive/scivisStudio/modals/AddDatasetDialog.cpp @@ -78,7 +78,8 @@ void AddDatasetDialog::buildUI() if (ImGui::Button("...##datasetSource")) { m_browsedSourcePath.clear(); - m_app->getFilenameFromDialog(m_browsedSourcePath); + m_app->getFilenameFromDialog( + m_browsedSourcePath, tsd::ui::imgui::FileDialogMode::OpenFile); } ImGui::SameLine(); ImGui::InputText("Source Path", m_sourcePath.data(), m_sourcePath.size()); diff --git a/tsd/apps/interactive/scivisStudio/modals/ProjectLocationDialog.cpp b/tsd/apps/interactive/scivisStudio/modals/ProjectLocationDialog.cpp index 957c53937..8837e0e23 100644 --- a/tsd/apps/interactive/scivisStudio/modals/ProjectLocationDialog.cpp +++ b/tsd/apps/interactive/scivisStudio/modals/ProjectLocationDialog.cpp @@ -5,10 +5,25 @@ #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") {} @@ -65,6 +80,18 @@ void ProjectLocationDialog::buildUI() } 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()); diff --git a/tsd/apps/interactive/scivisStudio/modals/ProjectLocationDialog.h b/tsd/apps/interactive/scivisStudio/modals/ProjectLocationDialog.h index 146629a31..e39f6decb 100644 --- a/tsd/apps/interactive/scivisStudio/modals/ProjectLocationDialog.h +++ b/tsd/apps/interactive/scivisStudio/modals/ProjectLocationDialog.h @@ -34,6 +34,7 @@ struct ProjectLocationDialog : public tsd::ui::imgui::Modal ProjectLocationMode m_mode{ProjectLocationMode::OpenProject}; std::function m_onAccept; std::array m_directory{}; + std::string m_browsedDirectory; std::string m_error; }; diff --git a/tsd/src/tsd/ui/imgui/Application.cpp b/tsd/src/tsd/ui/imgui/Application.cpp index 8f36a609f..58af3c647 100644 --- a/tsd/src/tsd/ui/imgui/Application.cpp +++ b/tsd/src/tsd/ui/imgui/Application.cpp @@ -98,7 +98,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 +120,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, diff --git a/tsd/src/tsd/ui/imgui/Application.h b/tsd/src/tsd/ui/imgui/Application.h index eb3897980..2d2205526 100644 --- a/tsd/src/tsd/ui/imgui/Application.h +++ b/tsd/src/tsd/ui/imgui/Application.h @@ -43,6 +43,13 @@ struct CommandLineOptions std::string secondaryViewportLibrary; }; +enum class FileDialogMode +{ + OpenFile, + SaveFile, + OpenDirectory +}; + class Application { public: @@ -60,7 +67,9 @@ 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); // Enqueue a task to be executed on a background thread template From 1983ce990e61b20810191da733335137dab17257 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 8 May 2026 08:56:51 -0500 Subject: [PATCH 11/49] allow selected objects to be editable after load --- tsd/apps/interactive/scivisStudio/Application.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tsd/apps/interactive/scivisStudio/Application.cpp b/tsd/apps/interactive/scivisStudio/Application.cpp index e19297ea8..35246396e 100644 --- a/tsd/apps/interactive/scivisStudio/Application.cpp +++ b/tsd/apps/interactive/scivisStudio/Application.cpp @@ -96,6 +96,8 @@ anari_viewer::WindowArray Application::setupWindows() if (m_viewport) m_viewport->setLibraryToDefault(); + ctx->tsd.sceneLoadComplete = true; + return windows; } From 4870663b767a22ed60094945272effe4a5d69727 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 8 May 2026 09:50:20 -0500 Subject: [PATCH 12/49] format --- .../interactive/scivisStudio/Application.cpp | 31 ++++++++++++------- .../scivisStudio/ProjectContext.cpp | 17 +++++----- .../interactive/scivisStudio/ProjectContext.h | 4 +-- .../scivisStudio/ProjectSerialization.cpp | 13 +++----- .../interactive/scivisStudio/RenderShot.cpp | 13 ++++---- .../scivisStudio/ShotCameraRig.cpp | 3 +- .../modals/ConfirmDiscardDialog.h | 3 +- .../scivisStudio/windows/CameraRigEditor.cpp | 18 +++++------ .../scivisStudio/windows/ProjectWindow.cpp | 8 +++-- .../scivisStudio/windows/ShotEditor.cpp | 3 +- 10 files changed, 62 insertions(+), 51 deletions(-) diff --git a/tsd/apps/interactive/scivisStudio/Application.cpp b/tsd/apps/interactive/scivisStudio/Application.cpp index 35246396e..a15c83b2d 100644 --- a/tsd/apps/interactive/scivisStudio/Application.cpp +++ b/tsd/apps/interactive/scivisStudio/Application.cpp @@ -59,8 +59,8 @@ anari_viewer::WindowArray Application::setupWindows() 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 *shotEditor = new ShotEditor( - this, &m_projectContext, [this]() { renderActiveShot(); }); + 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); @@ -149,8 +149,11 @@ bool Application::saveProjectAs(const std::filesystem::path &directory) saveApplicationSettings(root); std::string error; - const bool ok = m_projectContext.saveProject( - directory, root.child("windows"), saveLayout(), root.child("settings"), &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()); return ok; @@ -161,8 +164,11 @@ bool Application::openProject(const std::filesystem::path &directory) 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); + 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()); return false; @@ -193,8 +199,7 @@ void Application::requestDirtyAction(PendingDirtyAction action) } m_pendingDirtyAction = action; - m_confirmDiscardDialog->configure( - [this]() { continueDirtyAction(); }, + m_confirmDiscardDialog->configure([this]() { continueDirtyAction(); }, [this]() { m_pendingDirtyAction = PendingDirtyAction::None; }); m_confirmDiscardDialog->show(); } @@ -228,14 +233,17 @@ void Application::showProjectLocationDialogForNew() void Application::showProjectLocationDialogForOpen() { m_projectLocationDialog->configure(ProjectLocationMode::OpenProject, - [this](const std::filesystem::path &directory) { openProject(directory); }); + [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); }); + [this](const std::filesystem::path &directory) { + saveProjectAs(directory); + }); m_projectLocationDialog->show(); } @@ -290,7 +298,8 @@ void Application::saveDefaultLayoutFile() const 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'", + tsd::core::logError( + "[SciVisStudio] Failed to open default layout file '%s'", DEFAULT_LAYOUT_FILE); return; } diff --git a/tsd/apps/interactive/scivisStudio/ProjectContext.cpp b/tsd/apps/interactive/scivisStudio/ProjectContext.cpp index 35f44939b..d1ab78c6a 100644 --- a/tsd/apps/interactive/scivisStudio/ProjectContext.cpp +++ b/tsd/apps/interactive/scivisStudio/ProjectContext.cpp @@ -103,7 +103,8 @@ tsd::scene::LayerNodeRef ProjectContext::resolve(const SceneNodeRef &ref) const tsd::scene::Object *ProjectContext::resolve(const SceneObjectRef &ref) const { - if (!m_ctx || ref.type == ANARI_UNKNOWN || ref.objectIndex == TSD_INVALID_INDEX) + if (!m_ctx || ref.type == ANARI_UNKNOWN + || ref.objectIndex == TSD_INVALID_INDEX) return nullptr; return m_ctx->tsd.scene.getObject(ref.type, ref.objectIndex); } @@ -173,8 +174,9 @@ bool ProjectContext::addShot(const std::string &name) Shot shot; shot.id = nextShotId(m_project); - shot.name = name.empty() ? ("Shot " + std::to_string(m_project.shots.size() + 1)) - : name; + shot.name = name.empty() + ? ("Shot " + std::to_string(m_project.shots.size() + 1)) + : name; shot.renderSettings.outputFilePrefix = shot.id; ensureRendererDefaults(shot); @@ -248,7 +250,8 @@ Dataset *ProjectContext::addStaticDataset(const std::string &name, dataset.name = name.empty() ? dataset.id : name; dataset.sourceKind = DatasetSourceKind::Static; dataset.importerType = toString(importerType); - dataset.source = collectSourceMetadata(sourcePath, m_project.projectDirectory); + dataset.source = + collectSourceMetadata(sourcePath, m_project.projectDirectory); dataset.status = DatasetStatus::Importing; auto datasetRoot = ensureChild(ensureDatasetsRoot(), dataset.id.c_str()); @@ -268,8 +271,7 @@ Dataset *ProjectContext::addStaticDataset(const std::string &name, setDatasetBinding(shot, record.id, &shot == activeShot(m_project)); } catch (const std::exception &e) { record.status = DatasetStatus::ImportFailed; - tsd::core::logError( - "[SciVisStudio] Dataset import failed for '%s': %s", + tsd::core::logError("[SciVisStudio] Dataset import failed for '%s': %s", sourcePath.string().c_str(), e.what()); } catch (...) { @@ -329,8 +331,7 @@ bool ProjectContext::saveProject(const std::filesystem::path &directory, std::error_code ec; const auto manifest = directory / PROJECT_MANIFEST_FILENAME; - const bool savingCurrent = - !m_project.projectDirectory.empty() + const bool savingCurrent = !m_project.projectDirectory.empty() && std::filesystem::equivalent(directory, m_project.projectDirectory, ec); if (std::filesystem::exists(manifest) && !savingCurrent) { diff --git a/tsd/apps/interactive/scivisStudio/ProjectContext.h b/tsd/apps/interactive/scivisStudio/ProjectContext.h index 34516efa8..37acfa507 100644 --- a/tsd/apps/interactive/scivisStudio/ProjectContext.h +++ b/tsd/apps/interactive/scivisStudio/ProjectContext.h @@ -44,8 +44,8 @@ struct ProjectContext 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; + SceneNodeRef refFor( + const std::string &layerName, tsd::scene::LayerNodeRef ref) const; private: tsd::scene::LayerNodeRef ensureChild( diff --git a/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp b/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp index 3b27df677..82ada9afe 100644 --- a/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp +++ b/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp @@ -56,8 +56,7 @@ static void nodeToManipulatorState( tsd::io::nodeToCameraPose(*orbit, state.orbit); } -static void cameraRigToNode( - const ShotCameraRig &rig, tsd::core::DataNode &node) +static void cameraRigToNode(const ShotCameraRig &rig, tsd::core::DataNode &node) { manipulatorStateToNode(rig.current, node["current"]); auto &keyframes = node["keyframes"]; @@ -158,8 +157,7 @@ bool nodeToProject(tsd::core::DataNode &node, Project &project) { Project out; out.name = node["name"].getValueOr("Untitled"); - out.projectDirectory = - node["projectDirectory"].getValueOr(""); + out.projectDirectory = node["projectDirectory"].getValueOr(""); out.activeShotId = node["activeShot"].getValueOr(""); out.dirty = node["dirty"].getValueOr(false); @@ -171,8 +169,8 @@ bool nodeToProject(tsd::core::DataNode &node, Project &project) dataset.sourceKind = datasetSourceKindFromString( d["sourceKind"].getValueOr("Static")); dataset.importerType = d["importerType"].getValueOr("NONE"); - dataset.status = - datasetStatusFromString(d["status"].getValueOr("Missing")); + dataset.status = datasetStatusFromString( + d["status"].getValueOr("Missing")); if (auto *rootNode = d.child("rootNode")) dataset.rootNode = nodeToSceneNodeRef(*rootNode); @@ -181,8 +179,7 @@ bool nodeToProject(tsd::core::DataNode &node, Project &project) (*source)["absolutePath"].getValueOr(""); dataset.source.projectRelativePath = (*source)["projectRelativePath"].getValueOr(""); - dataset.source.fileSize = - (*source)["fileSize"].getValueOr(0); + dataset.source.fileSize = (*source)["fileSize"].getValueOr(0); dataset.source.modifiedTime = (*source)["modifiedTime"].getValueOr(0); } diff --git a/tsd/apps/interactive/scivisStudio/RenderShot.cpp b/tsd/apps/interactive/scivisStudio/RenderShot.cpp index 2446428a3..d5a0bd7f1 100644 --- a/tsd/apps/interactive/scivisStudio/RenderShot.cpp +++ b/tsd/apps/interactive/scivisStudio/RenderShot.cpp @@ -67,17 +67,17 @@ bool renderActiveShotToFrames( } anari::commitParameters(device, device); - auto *renderIndex = - ctx->tsd.scene.updateDelegate() - .emplace( - ctx->tsd.scene, libName, device); + auto *renderIndex = ctx->tsd.scene.updateDelegate() + .emplace( + ctx->tsd.scene, libName, device); renderIndex->populate(); auto renderer = anari::newObject(device, subtype.c_str()); anari::commitParameters(device, renderer); tsd::rendering::ImagePipeline pipeline; - pipeline.setDimensions(shot->renderSettings.width, shot->renderSettings.height); + pipeline.setDimensions( + shot->renderSettings.width, shot->renderSettings.height); auto *anariPass = pipeline.emplace_back(device); anariPass->setRunAsync(false); @@ -116,8 +116,7 @@ bool renderActiveShotToFrames( projectContext.applyActiveShot(); std::ostringstream ss; - ss << prefix << '_' << std::setfill('0') << std::setw(4) << frame - << ".png"; + 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) { diff --git a/tsd/apps/interactive/scivisStudio/ShotCameraRig.cpp b/tsd/apps/interactive/scivisStudio/ShotCameraRig.cpp index fdaeb15b2..6f6f10c25 100644 --- a/tsd/apps/interactive/scivisStudio/ShotCameraRig.cpp +++ b/tsd/apps/interactive/scivisStudio/ShotCameraRig.cpp @@ -33,7 +33,8 @@ ManipulatorState manipulatorStateFromManipulator( { ManipulatorState state; state.orbit.lookat = m.at(); - state.orbit.azeldist = tsd::math::float3(m.azel().x, m.azel().y, m.distance()); + 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()); return state; diff --git a/tsd/apps/interactive/scivisStudio/modals/ConfirmDiscardDialog.h b/tsd/apps/interactive/scivisStudio/modals/ConfirmDiscardDialog.h index 057a839aa..aece359ad 100644 --- a/tsd/apps/interactive/scivisStudio/modals/ConfirmDiscardDialog.h +++ b/tsd/apps/interactive/scivisStudio/modals/ConfirmDiscardDialog.h @@ -14,7 +14,8 @@ struct ConfirmDiscardDialog : public tsd::ui::imgui::Modal explicit ConfirmDiscardDialog(tsd::ui::imgui::Application *app); ~ConfirmDiscardDialog() override; - void configure(std::function onDiscard, std::function onCancel); + void configure( + std::function onDiscard, std::function onCancel); private: void buildUI() override; diff --git a/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp b/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp index 5bf3147e4..b3393311b 100644 --- a/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp +++ b/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp @@ -78,7 +78,8 @@ void CameraRigEditor::buildUI() } ImGui::EndDisabled(); - if (ImGui::BeginTable("keyframes", 4, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { + if (ImGui::BeginTable( + "keyframes", 4, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { ImGui::TableSetupColumn("Frame"); ImGui::TableSetupColumn("Name"); ImGui::TableSetupColumn("Interpolation"); @@ -90,7 +91,8 @@ void CameraRigEditor::buildUI() ImGui::PushID(i); ImGui::TableNextRow(); ImGui::TableNextColumn(); - if (ImGui::Selectable("##select", m_selectedKeyframe == i, + if (ImGui::Selectable("##select", + m_selectedKeyframe == i, ImGuiSelectableFlags_SpanAllColumns)) m_selectedKeyframe = i; ImGui::SameLine(); @@ -112,18 +114,16 @@ void CameraRigEditor::buildUI() keyframe.interpolationToNext == CameraInterpolation::Hold ? 0 : 1; const char *items[] = {"Hold", "Linear"}; if (ImGui::Combo("##interp", &interpolation, items, 2)) { - keyframe.interpolationToNext = - interpolation == 0 ? CameraInterpolation::Hold - : CameraInterpolation::Linear; + keyframe.interpolationToNext = interpolation == 0 + ? CameraInterpolation::Hold + : CameraInterpolation::Linear; 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::Text( + "%.2f %.2f %.2f", pose.azeldist.x, pose.azeldist.y, pose.azeldist.z); ImGui::PopID(); } diff --git a/tsd/apps/interactive/scivisStudio/windows/ProjectWindow.cpp b/tsd/apps/interactive/scivisStudio/windows/ProjectWindow.cpp index 9631b4dd3..a80996894 100644 --- a/tsd/apps/interactive/scivisStudio/windows/ProjectWindow.cpp +++ b/tsd/apps/interactive/scivisStudio/windows/ProjectWindow.cpp @@ -22,15 +22,17 @@ void ProjectWindow::buildUI() 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()); + 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(), toString(dataset.status)); + ImGui::BulletText( + "%s [%s]", dataset.name.c_str(), toString(dataset.status)); ImGui::SeparatorText("Shots"); for (auto &shot : project.shots) { diff --git a/tsd/apps/interactive/scivisStudio/windows/ShotEditor.cpp b/tsd/apps/interactive/scivisStudio/windows/ShotEditor.cpp index 594772e04..eb3fb7b32 100644 --- a/tsd/apps/interactive/scivisStudio/windows/ShotEditor.cpp +++ b/tsd/apps/interactive/scivisStudio/windows/ShotEditor.cpp @@ -21,7 +21,8 @@ ShotEditor::ShotEditor(tsd::ui::imgui::Application *app, ShotEditor::~ShotEditor() = default; -bool ShotEditor::inputText(const char *label, std::string &value, size_t capacity) +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); From 815c976284d6f302dd195c4408f8f34aa7fc9511 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 8 May 2026 17:37:36 -0500 Subject: [PATCH 13/49] shots should use the animation manager for time --- .../interactive/scivisStudio/Application.cpp | 37 ++--------- .../interactive/scivisStudio/Application.h | 2 - .../scivisStudio/ProjectContext.cpp | 65 ++++++++++++++++++- .../interactive/scivisStudio/ProjectContext.h | 4 ++ .../interactive/scivisStudio/RenderShot.cpp | 8 ++- .../scivisStudio/windows/CameraRigEditor.cpp | 5 +- .../scivisStudio/windows/ProjectWindow.cpp | 1 + .../scivisStudio/windows/ShotEditor.cpp | 51 +++++++++++---- tsd/tests/test_SciVisStudio.cpp | 24 +++++++ 9 files changed, 146 insertions(+), 51 deletions(-) diff --git a/tsd/apps/interactive/scivisStudio/Application.cpp b/tsd/apps/interactive/scivisStudio/Application.cpp index a15c83b2d..3dc82f629 100644 --- a/tsd/apps/interactive/scivisStudio/Application.cpp +++ b/tsd/apps/interactive/scivisStudio/Application.cpp @@ -264,34 +264,6 @@ void Application::renderActiveShot() "Rendering Active Shot..."); } -void Application::tickShotPlayback(float deltaTime) -{ - auto *shot = activeShot(m_projectContext.project()); - if (!shot || !shot->playing || shot->fps <= 0.f) - return; - - m_playbackAccumulator += deltaTime; - const float frameDuration = 1.f / shot->fps; - if (m_playbackAccumulator < frameDuration) - return; - - int steps = static_cast(m_playbackAccumulator / frameDuration); - m_playbackAccumulator -= steps * frameDuration; - while (steps-- > 0 && shot->playing) { - ++shot->currentFrame; - if (shot->currentFrame >= shot->frameCount) { - if (shot->loop) - shot->currentFrame = 0; - else { - shot->currentFrame = std::max(0, shot->frameCount - 1); - shot->playing = false; - } - } - } - - m_projectContext.applyActiveShot(); -} - void Application::saveDefaultLayoutFile() const { const std::string layout = ImGui::SaveIniSettingsToMemory(); @@ -322,7 +294,10 @@ void Application::saveDefaultLayoutFile() const void Application::uiFrameStart() { const ImGuiIO &io = ImGui::GetIO(); - tickShotPlayback(io.DeltaTime); + auto &animMgr = appContext()->tsd.animationMgr; + animMgr.tick(io.DeltaTime); + if (auto *shot = activeShot(m_projectContext.project())) + shot->playing = animMgr.isPlaying(); if (ImGui::BeginMainMenuBar()) { uiMainMenuBar(); @@ -352,8 +327,8 @@ void Application::uiFrameStart() if (!io.WantTextInput && ImGui::IsKeyPressed(ImGuiKey_Space)) { if (auto *shot = activeShot(m_projectContext.project())) { - shot->playing = !shot->playing; - m_playbackAccumulator = 0.f; + animMgr.togglePlay(); + shot->playing = animMgr.isPlaying(); } } diff --git a/tsd/apps/interactive/scivisStudio/Application.h b/tsd/apps/interactive/scivisStudio/Application.h index 176e81ae2..9d908a535 100644 --- a/tsd/apps/interactive/scivisStudio/Application.h +++ b/tsd/apps/interactive/scivisStudio/Application.h @@ -64,7 +64,6 @@ class Application : public tsd::ui::imgui::Application bool openProject(const std::filesystem::path &directory); void newProject(); void closeProject(); - void tickShotPlayback(float deltaTime); void saveDefaultLayoutFile() const; void saveWindowSettings(tsd::core::DataNode &node); void loadWindowSettings(tsd::core::DataNode &node); @@ -76,7 +75,6 @@ class Application : public tsd::ui::imgui::Application ProjectContext m_projectContext; std::filesystem::path m_initialProjectDirectory; PendingDirtyAction m_pendingDirtyAction{PendingDirtyAction::None}; - float m_playbackAccumulator{0.f}; tsd::ui::imgui::Viewport *m_viewport{nullptr}; tsd::ui::imgui::LayerTree *m_layerTree{nullptr}; diff --git a/tsd/apps/interactive/scivisStudio/ProjectContext.cpp b/tsd/apps/interactive/scivisStudio/ProjectContext.cpp index d1ab78c6a..0f28de014 100644 --- a/tsd/apps/interactive/scivisStudio/ProjectContext.cpp +++ b/tsd/apps/interactive/scivisStudio/ProjectContext.cpp @@ -17,11 +17,15 @@ namespace tsd::scivis_studio { -ProjectContext::ProjectContext(tsd::app::Context *ctx) : m_ctx(ctx) {} +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 @@ -50,6 +54,15 @@ void ProjectContext::resetScene() 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) { @@ -164,6 +177,7 @@ void ProjectContext::createUnsavedProject() m_project.shots.push_back(std::move(shot)); m_project.activeShotId = m_project.shots.front().id; m_project.markClean(); + syncAnimationManagerToActiveShot(); applyActiveShot(); } @@ -207,6 +221,7 @@ bool ProjectContext::addShot(const std::string &name) m_project.activeShotId = shot.id; m_project.shots.push_back(std::move(shot)); m_project.markDirty(); + syncAnimationManagerToActiveShot(); applyActiveShot(); return true; } @@ -317,6 +332,51 @@ void ProjectContext::applyActiveShot() } } +void ProjectContext::syncAnimationManagerToActiveShot() +{ + if (!m_ctx) + return; + + auto *shot = 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 = 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, @@ -418,13 +478,16 @@ bool ProjectContext::openProject(const std::filesystem::path &directory, 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); markMissingDatasets(); + syncAnimationManagerToActiveShot(); if (windowsOut) { windowsOut->reset(); diff --git a/tsd/apps/interactive/scivisStudio/ProjectContext.h b/tsd/apps/interactive/scivisStudio/ProjectContext.h index 37acfa507..8bc7b9628 100644 --- a/tsd/apps/interactive/scivisStudio/ProjectContext.h +++ b/tsd/apps/interactive/scivisStudio/ProjectContext.h @@ -30,6 +30,7 @@ struct ProjectContext const std::filesystem::path &sourcePath, tsd::io::ImporterType importerType); void applyActiveShot(); + void syncAnimationManagerToActiveShot(); bool saveProject(const std::filesystem::path &directory, tsd::core::DataNode *windows = nullptr, @@ -56,9 +57,12 @@ struct ProjectContext void resetScene(); void ensureRendererDefaults(Shot &shot); void markMissingDatasets(); + void installAnimationManagerCallback(); + void updateActiveShotFromAnimationTime(); tsd::app::Context *m_ctx{nullptr}; Project m_project; + bool m_syncingAnimationManager{false}; }; const char *toString(tsd::io::ImporterType importerType); diff --git a/tsd/apps/interactive/scivisStudio/RenderShot.cpp b/tsd/apps/interactive/scivisStudio/RenderShot.cpp index d5a0bd7f1..7946569c0 100644 --- a/tsd/apps/interactive/scivisStudio/RenderShot.cpp +++ b/tsd/apps/interactive/scivisStudio/RenderShot.cpp @@ -99,10 +99,13 @@ bool renderActiveShotToFrames( } 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, @@ -112,8 +115,7 @@ bool renderActiveShotToFrames( if (progress && progress->onFrame && !progress->onFrame(frame, totalFrames)) break; - shot->currentFrame = frame; - projectContext.applyActiveShot(); + ctx->tsd.animationMgr.setAnimationFrame(frame); std::ostringstream ss; ss << prefix << '_' << std::setfill('0') << std::setw(4) << frame << ".png"; @@ -126,6 +128,8 @@ bool renderActiveShotToFrames( } shot->currentFrame = savedFrame; + shot->playing = savedPlaying; + projectContext.syncAnimationManagerToActiveShot(); projectContext.applyActiveShot(); ctx->tsd.scene.updateDelegate().erase(renderIndex); diff --git a/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp b/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp index b3393311b..b784ac6c0 100644 --- a/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp +++ b/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp @@ -68,7 +68,10 @@ void CameraRigEditor::buildUI() ImGui::SameLine(); if (ImGui::Button("Jump Viewport To Keyframe")) { shot->currentFrame = rig.keyframes[m_selectedKeyframe].frame; - m_projectContext->applyActiveShot(); + if (ctx) + ctx->tsd.animationMgr.setAnimationFrame(shot->currentFrame); + else + m_projectContext->applyActiveShot(); } ImGui::SameLine(); if (ImGui::Button("Delete Keyframe")) { diff --git a/tsd/apps/interactive/scivisStudio/windows/ProjectWindow.cpp b/tsd/apps/interactive/scivisStudio/windows/ProjectWindow.cpp index a80996894..56a6ef1bf 100644 --- a/tsd/apps/interactive/scivisStudio/windows/ProjectWindow.cpp +++ b/tsd/apps/interactive/scivisStudio/windows/ProjectWindow.cpp @@ -39,6 +39,7 @@ void ProjectWindow::buildUI() 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(); } } diff --git a/tsd/apps/interactive/scivisStudio/windows/ShotEditor.cpp b/tsd/apps/interactive/scivisStudio/windows/ShotEditor.cpp index eb3fb7b32..e1c8fbb9a 100644 --- a/tsd/apps/interactive/scivisStudio/windows/ShotEditor.cpp +++ b/tsd/apps/interactive/scivisStudio/windows/ShotEditor.cpp @@ -44,29 +44,52 @@ void ShotEditor::buildUI() ImGui::TextDisabled("No active shot"); return; } + auto *ctx = m_projectContext->appContext(); if (inputText("Name", shot->name)) project.markDirty(); - bool changed = false; - changed |= ImGui::InputInt("Current frame", &shot->currentFrame); - changed |= ImGui::InputInt("Frame count", &shot->frameCount); - changed |= ImGui::InputFloat("FPS", &shot->fps); - 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); - - if (ImGui::Button(shot->playing ? "Stop" : "Play")) - shot->playing = !shot->playing; - ImGui::SameLine(); - if (ImGui::Checkbox("Loop", &shot->loop)) + 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 (changed) { + 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); diff --git a/tsd/tests/test_SciVisStudio.cpp b/tsd/tests/test_SciVisStudio.cpp index c1f9c9333..d881c2e1c 100644 --- a/tsd/tests/test_SciVisStudio.cpp +++ b/tsd/tests/test_SciVisStudio.cpp @@ -115,3 +115,27 @@ SCENARIO("SciVis Studio default project creation", "[SciVisStudio]") REQUIRE(project.dirty == false); REQUIRE(appContext.tsd.scene.layer("studio") != nullptr); } + +SCENARIO("SciVis Studio shot time is driven by the animation manager", + "[SciVisStudio]") +{ + tsd::app::Context appContext; + ProjectContext projectContext(&appContext); + projectContext.createUnsavedProject(); + + auto &shot = *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); +} From fa0f47b95f1495298d89b2fd0981293c18159d88 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 8 May 2026 22:41:57 -0500 Subject: [PATCH 14/49] signal layer changes when dataset visibility is toggled --- .../scivisStudio/ProjectContext.cpp | 24 +++++++-- tsd/tests/test_SciVisStudio.cpp | 53 +++++++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/tsd/apps/interactive/scivisStudio/ProjectContext.cpp b/tsd/apps/interactive/scivisStudio/ProjectContext.cpp index 0f28de014..bb2c06bd2 100644 --- a/tsd/apps/interactive/scivisStudio/ProjectContext.cpp +++ b/tsd/apps/interactive/scivisStudio/ProjectContext.cpp @@ -310,19 +310,35 @@ void ProjectContext::applyActiveShot() if (!shot) return; + std::vector changedLayers; + auto setNodeEnabled = [&](const SceneNodeRef &ref, bool enabled) { + if (auto node = resolve(ref)) { + 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 &s : m_project.shots) { - if (auto node = resolve(s.lightGroup)) - (*node)->setEnabled(s.id == shot->id); + setNodeEnabled(s.lightGroup, s.id == shot->id); } for (const auto &dataset : m_project.datasets) { bool enabled = false; if (const auto *binding = findDatasetBinding(*shot, dataset.id)) enabled = binding->enabled; - if (auto node = resolve(dataset.rootNode)) - (*node)->setEnabled(enabled); + setNodeEnabled(dataset.rootNode, enabled); } + for (auto *layer : changedLayers) + m_ctx->tsd.scene.signalLayerStructureChanged(layer); + auto sampled = sampleCameraRig(shot->cameraRig, shot->currentFrame); applyManipulatorState(m_ctx->view.manipulator, sampled); diff --git a/tsd/tests/test_SciVisStudio.cpp b/tsd/tests/test_SciVisStudio.cpp index d881c2e1c..14735f7bc 100644 --- a/tsd/tests/test_SciVisStudio.cpp +++ b/tsd/tests/test_SciVisStudio.cpp @@ -8,11 +8,28 @@ #include "tsd/app/Context.h" #include "tsd/core/DataTree.hpp" +#include "tsd/scene/UpdateDelegate.hpp" #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}; +}; + +} // namespace + SCENARIO("SciVis Studio project model serialization", "[SciVisStudio]") { GIVEN("A project with datasets, shots, and camera keyframes") @@ -116,6 +133,42 @@ SCENARIO("SciVis Studio default project creation", "[SciVisStudio]") REQUIRE(appContext.tsd.scene.layer("studio") != nullptr); } +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 = *activeShot(project); + 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 shot time is driven by the animation manager", "[SciVisStudio]") { From 2c30cc76d979c05911521be18797a633b46dfd8a Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 8 May 2026 22:51:01 -0500 Subject: [PATCH 15/49] fix layer/node ref resolution surviving project sessions --- .../scivisStudio/ProjectContext.cpp | 105 +++++++++++++--- .../interactive/scivisStudio/ProjectContext.h | 4 + .../scivisStudio/ProjectSerialization.cpp | 43 ------- .../interactive/scivisStudio/RenderShot.cpp | 2 +- tsd/tests/test_SciVisStudio.cpp | 114 +++++++++++++++++- 5 files changed, 205 insertions(+), 63 deletions(-) diff --git a/tsd/apps/interactive/scivisStudio/ProjectContext.cpp b/tsd/apps/interactive/scivisStudio/ProjectContext.cpp index bb2c06bd2..600c73447 100644 --- a/tsd/apps/interactive/scivisStudio/ProjectContext.cpp +++ b/tsd/apps/interactive/scivisStudio/ProjectContext.cpp @@ -17,6 +17,22 @@ 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 {}; +} + ProjectContext::ProjectContext(tsd::app::Context *ctx) : m_ctx(ctx) { installAnimationManagerCallback(); @@ -66,18 +82,7 @@ void ProjectContext::installAnimationManagerCallback() tsd::scene::LayerNodeRef ProjectContext::ensureChild( tsd::scene::LayerNodeRef parent, const char *name) { - tsd::scene::LayerNodeRef found; - if (!parent) - return {}; - - auto child = parent->next(); - while (child && child != parent) { - if ((*child)->name() == name) - found = child; - child = child->sibling(); - } - - if (found) + if (auto found = findDirectChild(parent, name)) return found; return m_ctx->tsd.scene.insertChildNode(parent, name); @@ -122,6 +127,58 @@ tsd::scene::Object *ProjectContext::resolve(const SceneObjectRef &ref) const 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::resolveShotLightGroup(Shot &shot) +{ + if (!m_ctx) + return {}; + + auto *layer = m_ctx->tsd.scene.layer("studio"); + if (layer) { + auto shotsRoot = findDirectChild(layer->root(), "shots"); + auto shotRoot = findDirectChild(shotsRoot, shot.id); + auto lightsRoot = findDirectChild(shotRoot, "lights"); + if (lightsRoot) { + shot.lightGroup = refFor("studio", lightsRoot); + return lightsRoot; + } + } + + return resolve(shot.lightGroup); +} + +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()) @@ -311,8 +368,8 @@ void ProjectContext::applyActiveShot() return; std::vector changedLayers; - auto setNodeEnabled = [&](const SceneNodeRef &ref, bool enabled) { - if (auto node = resolve(ref)) { + auto setNodeEnabled = [&](tsd::scene::LayerNodeRef node, bool enabled) { + if (node) { if ((*node)->isEnabled() == enabled) return; @@ -326,14 +383,14 @@ void ProjectContext::applyActiveShot() }; for (auto &s : m_project.shots) { - setNodeEnabled(s.lightGroup, s.id == shot->id); + setNodeEnabled(resolveShotLightGroup(s), s.id == shot->id); } - for (const auto &dataset : m_project.datasets) { + for (auto &dataset : m_project.datasets) { bool enabled = false; if (const auto *binding = findDatasetBinding(*shot, dataset.id)) enabled = binding->enabled; - setNodeEnabled(dataset.rootNode, enabled); + setNodeEnabled(resolveDatasetRoot(dataset), enabled); } for (auto *layer : changedLayers) @@ -342,7 +399,7 @@ void ProjectContext::applyActiveShot() auto sampled = sampleCameraRig(shot->cameraRig, shot->currentFrame); applyManipulatorState(m_ctx->view.manipulator, sampled); - if (auto *obj = resolve(shot->camera)) { + if (auto *obj = resolveShotCamera(*shot)) { auto *camera = static_cast(obj); tsd::rendering::updateCameraObject(*camera, m_ctx->view.manipulator); } @@ -503,6 +560,7 @@ bool ProjectContext::openProject(const std::filesystem::path &directory, loadedProject.markClean(); m_project = std::move(loadedProject); markMissingDatasets(); + refreshRuntimeRefs(); syncAnimationManagerToActiveShot(); if (windowsOut) { @@ -541,6 +599,17 @@ void ProjectContext::markMissingDatasets() } } +void ProjectContext::refreshRuntimeRefs() +{ + for (auto &dataset : m_project.datasets) + resolveDatasetRoot(dataset); + + for (auto &shot : m_project.shots) { + resolveShotLightGroup(shot); + resolveShotCamera(shot); + } +} + const char *toString(tsd::io::ImporterType importerType) { switch (importerType) { diff --git a/tsd/apps/interactive/scivisStudio/ProjectContext.h b/tsd/apps/interactive/scivisStudio/ProjectContext.h index 8bc7b9628..943410de0 100644 --- a/tsd/apps/interactive/scivisStudio/ProjectContext.h +++ b/tsd/apps/interactive/scivisStudio/ProjectContext.h @@ -47,6 +47,9 @@ struct ProjectContext 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 resolveShotLightGroup(Shot &shot); + tsd::scene::Object *resolveShotCamera(Shot &shot); private: tsd::scene::LayerNodeRef ensureChild( @@ -57,6 +60,7 @@ struct ProjectContext void resetScene(); void ensureRendererDefaults(Shot &shot); void markMissingDatasets(); + void refreshRuntimeRefs(); void installAnimationManagerCallback(); void updateActiveShotFromAnimationTime(); diff --git a/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp b/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp index 82ada9afe..2c4841fb8 100644 --- a/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp +++ b/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp @@ -9,40 +9,6 @@ namespace tsd::scivis_studio { -static void sceneNodeRefToNode( - const SceneNodeRef &ref, tsd::core::DataNode &node) -{ - node["layerName"] = ref.layerName; - node["nodeIndex"] = static_cast(ref.nodeIndex); -} - -static SceneNodeRef nodeToSceneNodeRef(tsd::core::DataNode &node) -{ - SceneNodeRef ref; - if (auto *c = node.child("layerName")) - ref.layerName = c->getValueAs(); - if (auto *c = node.child("nodeIndex")) - ref.nodeIndex = static_cast(c->getValueAs()); - return ref; -} - -static void sceneObjectRefToNode( - const SceneObjectRef &ref, tsd::core::DataNode &node) -{ - node["type"] = static_cast(ref.type); - node["objectIndex"] = static_cast(ref.objectIndex); -} - -static SceneObjectRef nodeToSceneObjectRef(tsd::core::DataNode &node) -{ - SceneObjectRef ref; - if (auto *c = node.child("type")) - ref.type = static_cast(c->getValueAs()); - if (auto *c = node.child("objectIndex")) - ref.objectIndex = static_cast(c->getValueAs()); - return ref; -} - static void manipulatorStateToNode( const ManipulatorState &state, tsd::core::DataNode &node) { @@ -106,7 +72,6 @@ void projectToNode(const Project &project, tsd::core::DataNode &node) d["sourceKind"] = toString(dataset.sourceKind); d["importerType"] = dataset.importerType; d["status"] = toString(dataset.status); - sceneNodeRefToNode(dataset.rootNode, d["rootNode"]); auto &source = d["source"]; source["absolutePath"] = dataset.source.absolutePath; @@ -125,8 +90,6 @@ void projectToNode(const Project &project, tsd::core::DataNode &node) s["currentFrame"] = shot.currentFrame; s["playing"] = shot.playing; s["loop"] = shot.loop; - sceneNodeRefToNode(shot.lightGroup, s["lightGroup"]); - sceneObjectRefToNode(shot.camera, s["camera"]); cameraRigToNode(shot.cameraRig, s["cameraRig"]); auto &render = s["renderSettings"]; @@ -171,8 +134,6 @@ bool nodeToProject(tsd::core::DataNode &node, Project &project) dataset.importerType = d["importerType"].getValueOr("NONE"); dataset.status = datasetStatusFromString( d["status"].getValueOr("Missing")); - if (auto *rootNode = d.child("rootNode")) - dataset.rootNode = nodeToSceneNodeRef(*rootNode); if (auto *source = d.child("source")) { dataset.source.absolutePath = @@ -197,10 +158,6 @@ bool nodeToProject(tsd::core::DataNode &node, Project &project) shot.currentFrame = s["currentFrame"].getValueOr(0); shot.playing = s["playing"].getValueOr(false); shot.loop = s["loop"].getValueOr(true); - if (auto *lightGroup = s.child("lightGroup")) - shot.lightGroup = nodeToSceneNodeRef(*lightGroup); - if (auto *camera = s.child("camera")) - shot.camera = nodeToSceneObjectRef(*camera); if (auto *cameraRig = s.child("cameraRig")) nodeToCameraRig(*cameraRig, shot.cameraRig); diff --git a/tsd/apps/interactive/scivisStudio/RenderShot.cpp b/tsd/apps/interactive/scivisStudio/RenderShot.cpp index 7946569c0..a883abded 100644 --- a/tsd/apps/interactive/scivisStudio/RenderShot.cpp +++ b/tsd/apps/interactive/scivisStudio/RenderShot.cpp @@ -29,7 +29,7 @@ bool renderActiveShotToFrames( return false; } - auto *cameraObject = projectContext.resolve(shot->camera); + auto *cameraObject = projectContext.resolveShotCamera(*shot); if (!cameraObject || cameraObject->type() != ANARI_CAMERA) { tsd::core::logError("[SciVisStudio] Active shot camera is missing"); return false; diff --git a/tsd/tests/test_SciVisStudio.cpp b/tsd/tests/test_SciVisStudio.cpp index 14735f7bc..6ae852270 100644 --- a/tsd/tests/test_SciVisStudio.cpp +++ b/tsd/tests/test_SciVisStudio.cpp @@ -28,6 +28,18 @@ struct CountingLayerUpdateDelegate : public tsd::scene::EmptyUpdateDelegate 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]") @@ -63,9 +75,14 @@ SCENARIO("SciVis Studio project model serialization", "[SciVisStudio]") tsd::core::DataTree tree; projectToNode(project, tree.root()["scivisStudio"]); + auto &serialized = tree.root()["scivisStudio"]; + + REQUIRE(serialized["datasets"].child(0)->child("rootNode") == nullptr); + REQUIRE(serialized["shots"].child(0)->child("lightGroup") == nullptr); + REQUIRE(serialized["shots"].child(0)->child("camera") == nullptr); Project loaded; - REQUIRE(nodeToProject(tree.root()["scivisStudio"], loaded)); + REQUIRE(nodeToProject(serialized, loaded)); THEN("IDs and keyframes survive round trip") { @@ -169,6 +186,101 @@ SCENARIO("SciVis Studio shot dataset bindings update scene visibility", 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 = *activeShot(project); + 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)}); + setDatasetBinding(*activeShot(project), "dataset_0001", false); + + REQUIRE(projectContext.saveProject(root)); + } + + { + tsd::core::DataTree manifest; + REQUIRE(manifest.load((root / PROJECT_MANIFEST_FILENAME).string().c_str())); + auto &projectNode = manifest.root()["scivisStudio"]; + REQUIRE(projectNode["datasets"].child(0)->child("rootNode") == nullptr); + REQUIRE(projectNode["shots"].child(0)->child("lightGroup") == nullptr); + REQUIRE(projectNode["shots"].child(0)->child("camera") == 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 shot time is driven by the animation manager", "[SciVisStudio]") { From f731c3530e47137b567c9bc76cd809bf9d87c061 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 15 May 2026 16:55:02 -0500 Subject: [PATCH 16/49] fixes after rebase --- tsd/apps/interactive/scivisStudio/Application.cpp | 2 +- tsd/apps/interactive/scivisStudio/Application.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tsd/apps/interactive/scivisStudio/Application.cpp b/tsd/apps/interactive/scivisStudio/Application.cpp index 3dc82f629..30ea88113 100644 --- a/tsd/apps/interactive/scivisStudio/Application.cpp +++ b/tsd/apps/interactive/scivisStudio/Application.cpp @@ -51,7 +51,7 @@ const ProjectContext &Application::projectContext() const return m_projectContext; } -anari_viewer::WindowArray Application::setupWindows() +tsd::ui::imgui::WindowArray Application::setupWindows() { auto windows = TSDApplication::setupWindows(); diff --git a/tsd/apps/interactive/scivisStudio/Application.h b/tsd/apps/interactive/scivisStudio/Application.h index 9d908a535..cb0df09b2 100644 --- a/tsd/apps/interactive/scivisStudio/Application.h +++ b/tsd/apps/interactive/scivisStudio/Application.h @@ -45,7 +45,7 @@ class Application : public tsd::ui::imgui::Application void renderActiveShot(); protected: - anari_viewer::WindowArray setupWindows() override; + tsd::ui::imgui::WindowArray setupWindows() override; void uiFrameStart() override; void teardown() override; void uiMainMenuBar() override; From 38e98b7e52b325b8df76ae42f56f4b0d0a8bb4db Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 19 May 2026 12:56:03 -0500 Subject: [PATCH 17/49] add feature for externally loadeing color map presets --- tsd/apps/interactive/viewer/README.md | 18 ++++ tsd/config/README.md | 18 ++++ tsd/config/Sunset Test.1dt | 8 ++ tsd/src/tsd/io/importers.hpp | 16 ++++ .../io/importers/detail/importer_common.cpp | 91 +++++++++++++++++++ .../imgui/windows/TransferFunctionEditor.cpp | 31 ++++++- 6 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 tsd/config/README.md create mode 100644 tsd/config/Sunset Test.1dt 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/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/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/ui/imgui/windows/TransferFunctionEditor.cpp b/tsd/src/tsd/ui/imgui/windows/TransferFunctionEditor.cpp index dcd1d8095..bf5e0bb7a 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" @@ -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( From 65a57a5a50d09bae392eaf81594a50ba7c031d18 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 19 May 2026 13:43:03 -0500 Subject: [PATCH 18/49] add "look" mode to the viewport manipulator --- .../scivisStudio/ShotCameraRig.cpp | 2 + tsd/src/tsd/app/Context.cpp | 11 +- .../serialization/serialization_datatree.cpp | 2 + tsd/src/tsd/rendering/CMakeLists.txt | 1 + tsd/src/tsd/rendering/view/CameraPath.h | 1 + tsd/src/tsd/rendering/view/Manipulator.cpp | 317 ++++++++++++++++++ tsd/src/tsd/rendering/view/Manipulator.hpp | 238 +------------ .../tsd/rendering/view/ManipulatorToTSD.cpp | 11 +- tsd/src/tsd/ui/imgui/windows/BaseViewport.cpp | 8 +- .../ui/imgui/windows/MultiDeviceViewport.cpp | 22 +- tsd/src/tsd/ui/imgui/windows/Viewport.cpp | 32 +- tsd/tests/CMakeLists.txt | 3 + tsd/tests/test_Manipulator.cpp | 55 +++ 13 files changed, 460 insertions(+), 243 deletions(-) create mode 100644 tsd/src/tsd/rendering/view/Manipulator.cpp create mode 100644 tsd/tests/test_Manipulator.cpp diff --git a/tsd/apps/interactive/scivisStudio/ShotCameraRig.cpp b/tsd/apps/interactive/scivisStudio/ShotCameraRig.cpp index 6f6f10c25..70ba66f3f 100644 --- a/tsd/apps/interactive/scivisStudio/ShotCameraRig.cpp +++ b/tsd/apps/interactive/scivisStudio/ShotCameraRig.cpp @@ -37,6 +37,7 @@ ManipulatorState manipulatorStateFromManipulator( 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; } @@ -108,6 +109,7 @@ ManipulatorState sampleCameraRig(const ShotCameraRig &rig, int frame) out.orbit.fixedDist = lerp(t, a.manipulator.orbit.fixedDist, b.manipulator.orbit.fixedDist); out.orbit.upAxis = a.manipulator.orbit.upAxis; + out.orbit.mode = a.manipulator.orbit.mode; return out; } 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/io/serialization/serialization_datatree.cpp b/tsd/src/tsd/io/serialization/serialization_datatree.cpp index 361a31db6..3a62067ed 100644 --- a/tsd/src/tsd/io/serialization/serialization_datatree.cpp +++ b/tsd/src/tsd/io/serialization/serialization_datatree.cpp @@ -296,6 +296,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 +306,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 ///////////////////////////////////////////////////////////////////// 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/ui/imgui/windows/BaseViewport.cpp b/tsd/src/tsd/ui/imgui/windows/BaseViewport.cpp index 0199792d8..faf4253a4 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(); @@ -714,8 +719,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/MultiDeviceViewport.cpp b/tsd/src/tsd/ui/imgui/windows/MultiDeviceViewport.cpp index 1e0fe3ce5..183bdeee1 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:"); diff --git a/tsd/src/tsd/ui/imgui/windows/Viewport.cpp b/tsd/src/tsd/ui/imgui/windows/Viewport.cpp index 3d0d844ed..c5e53d5cc 100644 --- a/tsd/src/tsd/ui/imgui/windows/Viewport.cpp +++ b/tsd/src/tsd/ui/imgui/windows/Viewport.cpp @@ -458,12 +458,21 @@ void Viewport::imagePipeline_populate(tsd::rendering::ImagePipeline &p) 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 +480,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; } diff --git a/tsd/tests/CMakeLists.txt b/tsd/tests/CMakeLists.txt index c2721d366..dda67a257 100644 --- a/tsd/tests/CMakeLists.txt +++ b/tsd/tests/CMakeLists.txt @@ -15,6 +15,7 @@ project_add_executable( test_FlatMap.cpp test_Forest.cpp test_Geometry.cpp + test_Manipulator.cpp test_ObjectPool.cpp test_Material.cpp test_Math.cpp @@ -32,6 +33,7 @@ PRIVATE tsd_algorithms tsd_animation tsd_io + tsd_rendering tsd_scene tsd_ext_catch2 ) @@ -47,6 +49,7 @@ 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::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]" ) 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)); + } + } +} From 5fc508438b5c7b1574995058c2c53f49df089c6f Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 19 May 2026 18:20:20 -0500 Subject: [PATCH 19/49] implement recent projects menu --- .../interactive/scivisStudio/Application.cpp | 212 +++++++++++++++++- .../interactive/scivisStudio/Application.h | 14 +- 2 files changed, 219 insertions(+), 7 deletions(-) diff --git a/tsd/apps/interactive/scivisStudio/Application.cpp b/tsd/apps/interactive/scivisStudio/Application.cpp index 30ea88113..6e90d67cb 100644 --- a/tsd/apps/interactive/scivisStudio/Application.cpp +++ b/tsd/apps/interactive/scivisStudio/Application.cpp @@ -22,14 +22,60 @@ #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); +} + +} // namespace + Application::Application(int argc, const char **argv) : TSDApplication(argc, argv), m_projectContext(appContext()) { @@ -54,6 +100,7 @@ const ProjectContext &Application::projectContext() const 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"); @@ -156,6 +203,8 @@ bool Application::saveProjectAs(const std::filesystem::path &directory) &error); if (!ok) tsd::core::logError("[SciVisStudio] Save failed: %s", error.c_str()); + else + addRecentProject(directory); return ok; } @@ -177,6 +226,7 @@ bool Application::openProject(const std::filesystem::path &directory) loadWindowSettings(scratch.root()["windows"]); loadLayout(layout); loadApplicationSettings(scratch.root()); + addRecentProject(directory); return true; } @@ -200,10 +250,20 @@ void Application::requestDirtyAction(PendingDirtyAction action) m_pendingDirtyAction = action; m_confirmDiscardDialog->configure([this]() { continueDirtyAction(); }, - [this]() { m_pendingDirtyAction = PendingDirtyAction::None; }); + [this]() { + m_pendingDirtyAction = PendingDirtyAction::None; + m_pendingProjectDirectory.clear(); + }); m_confirmDiscardDialog->show(); } +void Application::requestOpenRecentProject( + const std::filesystem::path &directory) +{ + m_pendingProjectDirectory = directory; + requestDirtyAction(PendingDirtyAction::OpenRecentProject); +} + void Application::continueDirtyAction() { const auto action = m_pendingDirtyAction; @@ -213,6 +273,143 @@ void Application::continueDirtyAction() showProjectLocationDialogForNew(); 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::showAddDatasetDialog() @@ -342,15 +539,18 @@ void Application::uiFrameStart() void Application::uiMainMenuBar() { if (ImGui::BeginMenu("Project")) { - if (ImGui::MenuItem("New Project...")) + if (ImGui::MenuItem("New ...")) requestDirtyAction(PendingDirtyAction::NewProject); - if (ImGui::MenuItem("Open Project...")) + if (ImGui::MenuItem("Open ...")) requestDirtyAction(PendingDirtyAction::OpenProject); - if (ImGui::MenuItem("Save Project", "Ctrl+S")) + ImGui::Separator(); + uiRecentProjectsMenu(); + ImGui::Separator(); + if (ImGui::MenuItem("Save", "Ctrl+S")) saveProject(); - if (ImGui::MenuItem("Save Project As...")) + if (ImGui::MenuItem("Save As...")) showProjectLocationDialogForSaveAs(); - if (ImGui::MenuItem("Close Project")) + if (ImGui::MenuItem("Close")) requestDirtyAction(PendingDirtyAction::NewProject); ImGui::Separator(); if (ImGui::MenuItem("Quit")) diff --git a/tsd/apps/interactive/scivisStudio/Application.h b/tsd/apps/interactive/scivisStudio/Application.h index cb0df09b2..5749e2a7a 100644 --- a/tsd/apps/interactive/scivisStudio/Application.h +++ b/tsd/apps/interactive/scivisStudio/Application.h @@ -10,6 +10,7 @@ #include #include #include +#include namespace tsd::ui::imgui { struct LayerTree; @@ -56,7 +57,8 @@ class Application : public tsd::ui::imgui::Application { None, NewProject, - OpenProject + OpenProject, + OpenRecentProject }; bool saveProject(); @@ -70,10 +72,20 @@ class Application : public tsd::ui::imgui::Application 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}; tsd::ui::imgui::Viewport *m_viewport{nullptr}; From d61f93cc027ba8a69d87071c3e7d54d89959a87f Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Wed, 20 May 2026 11:38:45 -0500 Subject: [PATCH 20/49] simplify buttons in camera rig window --- .../scivisStudio/windows/CameraRigEditor.cpp | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp b/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp index b784ac6c0..1fc144968 100644 --- a/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp +++ b/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp @@ -12,6 +12,12 @@ namespace tsd::scivis_studio { +static void tooltipForPreviousItem(const char *text) +{ + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + ImGui::SetTooltip("%s", text); +} + CameraRigEditor::CameraRigEditor( tsd::ui::imgui::Application *app, ProjectContext *projectContext) : Window(app, "Camera Rig"), m_projectContext(projectContext) @@ -34,14 +40,15 @@ void CameraRigEditor::buildUI() auto *ctx = m_projectContext->appContext(); auto &rig = shot->cameraRig; - if (ImGui::Button("Set Rig View From Viewport")) { + if (ImGui::Button("Set View")) { rig.current = manipulatorStateFromManipulator(ctx->view.manipulator); project.markDirty(); } + tooltipForPreviousItem("Set Rig View From Viewport"); ImGui::SameLine(); - if (ImGui::Button("Capture Keyframe At Current Frame")) { + if (ImGui::Button("Capture")) { CameraKeyframe keyframe; keyframe.frame = shot->currentFrame; keyframe.name = "Frame " + std::to_string(shot->currentFrame); @@ -52,6 +59,9 @@ void CameraRigEditor::buildUI() m_selectedKeyframe = static_cast(rig.keyframes.size()) - 1; project.markDirty(); } + tooltipForPreviousItem("Capture Keyframe At Current Frame"); + + ImGui::SameLine(); if (m_selectedKeyframe >= static_cast(rig.keyframes.size())) m_selectedKeyframe = rig.keyframes.empty() ? -1 : 0; @@ -60,25 +70,28 @@ void CameraRigEditor::buildUI() && m_selectedKeyframe < static_cast(rig.keyframes.size()); ImGui::BeginDisabled(!hasSelection); - if (ImGui::Button("Update Selected From Viewport")) { + if (ImGui::Button("Update")) { rig.keyframes[m_selectedKeyframe].manipulator = manipulatorStateFromManipulator(ctx->view.manipulator); project.markDirty(); } + tooltipForPreviousItem("Update Selected From Viewport"); ImGui::SameLine(); - if (ImGui::Button("Jump Viewport To Keyframe")) { + if (ImGui::Button("Jump")) { shot->currentFrame = rig.keyframes[m_selectedKeyframe].frame; if (ctx) ctx->tsd.animationMgr.setAnimationFrame(shot->currentFrame); else m_projectContext->applyActiveShot(); } + tooltipForPreviousItem("Jump Viewport To Keyframe"); ImGui::SameLine(); - if (ImGui::Button("Delete Keyframe")) { + if (ImGui::Button("Delete")) { rig.keyframes.erase(rig.keyframes.begin() + m_selectedKeyframe); m_selectedKeyframe = -1; project.markDirty(); } + tooltipForPreviousItem("Delete Keyframe"); ImGui::EndDisabled(); if (ImGui::BeginTable( From 4d28ae803bff098f1a7374f79503f61e662ce2c1 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Wed, 20 May 2026 11:44:10 -0500 Subject: [PATCH 21/49] move tooltip helper function to a generic place --- .../scivisStudio/windows/CameraRigEditor.cpp | 17 ++++++----------- tsd/src/tsd/ui/imgui/tsd_ui_imgui.cpp | 12 +++++++++++- tsd/src/tsd/ui/imgui/tsd_ui_imgui.h | 2 ++ 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp b/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp index 1fc144968..ebd8a3e27 100644 --- a/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp +++ b/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp @@ -5,6 +5,7 @@ #include "tsd/rendering/view/ManipulatorToTSD.hpp" #include "tsd/scene/objects/Camera.hpp" +#include "tsd/ui/imgui/tsd_ui_imgui.h" #include "imgui.h" @@ -12,12 +13,6 @@ namespace tsd::scivis_studio { -static void tooltipForPreviousItem(const char *text) -{ - if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) - ImGui::SetTooltip("%s", text); -} - CameraRigEditor::CameraRigEditor( tsd::ui::imgui::Application *app, ProjectContext *projectContext) : Window(app, "Camera Rig"), m_projectContext(projectContext) @@ -44,7 +39,7 @@ void CameraRigEditor::buildUI() rig.current = manipulatorStateFromManipulator(ctx->view.manipulator); project.markDirty(); } - tooltipForPreviousItem("Set Rig View From Viewport"); + tsd::ui::tooltipForPreviousItem("Set Rig View From Viewport"); ImGui::SameLine(); @@ -59,7 +54,7 @@ void CameraRigEditor::buildUI() m_selectedKeyframe = static_cast(rig.keyframes.size()) - 1; project.markDirty(); } - tooltipForPreviousItem("Capture Keyframe At Current Frame"); + tsd::ui::tooltipForPreviousItem("Capture Keyframe At Current Frame"); ImGui::SameLine(); @@ -75,7 +70,7 @@ void CameraRigEditor::buildUI() manipulatorStateFromManipulator(ctx->view.manipulator); project.markDirty(); } - tooltipForPreviousItem("Update Selected From Viewport"); + tsd::ui::tooltipForPreviousItem("Update Selected From Viewport"); ImGui::SameLine(); if (ImGui::Button("Jump")) { shot->currentFrame = rig.keyframes[m_selectedKeyframe].frame; @@ -84,14 +79,14 @@ void CameraRigEditor::buildUI() else m_projectContext->applyActiveShot(); } - tooltipForPreviousItem("Jump Viewport To Keyframe"); + 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(); } - tooltipForPreviousItem("Delete Keyframe"); + tsd::ui::tooltipForPreviousItem("Delete Keyframe"); ImGui::EndDisabled(); if (ImGui::BeginTable( diff --git a/tsd/src/tsd/ui/imgui/tsd_ui_imgui.cpp b/tsd/src/tsd/ui/imgui/tsd_ui_imgui.cpp index 13c500e96..d01ccd748 100644 --- a/tsd/src/tsd/ui/imgui/tsd_ui_imgui.cpp +++ b/tsd/src/tsd/ui/imgui/tsd_ui_imgui.cpp @@ -845,4 +845,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 From 7bee87703a1a062e5cb1a39b746dd49c22f83f77 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Wed, 20 May 2026 11:50:19 -0500 Subject: [PATCH 22/49] use new utility function in relevant places --- tsd/src/tsd/ui/imgui/Application.cpp | 16 ++-- .../tsd/ui/imgui/modals/AppSettingsDialog.cpp | 23 ++--- tsd/src/tsd/ui/imgui/tsd_ui_imgui.cpp | 8 +- tsd/src/tsd/ui/imgui/windows/BaseViewport.cpp | 3 +- tsd/src/tsd/ui/imgui/windows/CameraPoses.cpp | 94 ++++++++----------- tsd/src/tsd/ui/imgui/windows/LayerTree.cpp | 15 ++- tsd/src/tsd/ui/imgui/windows/Timeline.cpp | 22 +++-- .../imgui/windows/TransferFunctionEditor.cpp | 8 +- 8 files changed, 83 insertions(+), 106 deletions(-) diff --git a/tsd/src/tsd/ui/imgui/Application.cpp b/tsd/src/tsd/ui/imgui/Application.cpp index 58af3c647..b655ff1eb 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 @@ -491,28 +492,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(); @@ -684,8 +681,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()); } } } 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/tsd_ui_imgui.cpp b/tsd/src/tsd/ui/imgui/tsd_ui_imgui.cpp index d01ccd748..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(); diff --git a/tsd/src/tsd/ui/imgui/windows/BaseViewport.cpp b/tsd/src/tsd/ui/imgui/windows/BaseViewport.cpp index faf4253a4..c62df8646 100644 --- a/tsd/src/tsd/ui/imgui/windows/BaseViewport.cpp +++ b/tsd/src/tsd/ui/imgui/windows/BaseViewport.cpp @@ -542,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) { 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..e8077ff49 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) { 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 bf5e0bb7a..6b48affb8 100644 --- a/tsd/src/tsd/ui/imgui/windows/TransferFunctionEditor.cpp +++ b/tsd/src/tsd/ui/imgui/windows/TransferFunctionEditor.cpp @@ -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); } } From 5760ef30c21d153975f9f16164397c24d4ce1aae Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Wed, 20 May 2026 17:21:40 -0500 Subject: [PATCH 23/49] have viewport settings survive project close/open --- .../interactive/scivisStudio/Application.cpp | 72 ++++++++++++++++- .../interactive/scivisStudio/Application.h | 2 + .../scivisStudio/ProjectSerialization.cpp | 5 ++ .../interactive/scivisStudio/RenderShot.cpp | 78 ++++++++++++++----- tsd/apps/interactive/scivisStudio/Shot.h | 1 + tsd/src/tsd/ui/imgui/windows/Viewport.cpp | 76 +++++++++++++++--- tsd/src/tsd/ui/imgui/windows/Viewport.h | 5 +- tsd/tests/test_SciVisStudio.cpp | 8 ++ 8 files changed, 214 insertions(+), 33 deletions(-) diff --git a/tsd/apps/interactive/scivisStudio/Application.cpp b/tsd/apps/interactive/scivisStudio/Application.cpp index 6e90d67cb..a47961b43 100644 --- a/tsd/apps/interactive/scivisStudio/Application.cpp +++ b/tsd/apps/interactive/scivisStudio/Application.cpp @@ -135,13 +135,14 @@ tsd::ui::imgui::WindowArray Application::setupWindows() std::make_unique(this, &m_projectContext); if (!m_initialProjectDirectory.empty()) { - if (!openProject(m_initialProjectDirectory)) + if (!openProject(m_initialProjectDirectory)) { m_projectContext.createUnsavedProject(); - } else + m_viewport->setLibraryToDefault(); + } + } else { m_projectContext.createUnsavedProject(); - - if (m_viewport) m_viewport->setLibraryToDefault(); + } ctx->tsd.sceneLoadComplete = true; @@ -177,6 +178,58 @@ void Application::loadLayout(const std::string &layout) ImGui::LoadIniSettingsFromMemory(layout.c_str()); } +void Application::restoreViewportFromActiveShot() +{ + if (!m_viewport) + return; + + const auto *shot = activeShot(m_projectContext.project()); + if (!shot) { + m_viewport->setLibraryToDefault(); + return; + } + + m_viewport->setLibrary(shot->renderSettings.rendererLibrary, + shot->renderSettings.rendererObjectIndex); +} + +void Application::syncActiveShotRenderSettingsFromViewport() +{ + if (!m_viewport) + return; + + auto &project = m_projectContext.project(); + auto *shot = activeShot(project); + if (!shot) + return; + + const auto &libraryName = m_viewport->libraryName(); + const auto rendererIndex = m_viewport->currentRendererObjectIndex(); + if (libraryName.empty() || rendererIndex == TSD_INVALID_INDEX) + return; + + bool changed = false; + if (shot->renderSettings.rendererLibrary != libraryName) { + shot->renderSettings.rendererLibrary = libraryName; + changed = true; + } + if (shot->renderSettings.rendererObjectIndex != rendererIndex) { + shot->renderSettings.rendererObjectIndex = rendererIndex; + changed = true; + } + + auto *renderer = + appContext()->tsd.scene.getObject(ANARI_RENDERER, rendererIndex); + if (renderer + && shot->renderSettings.rendererSubtype != renderer->subtype().str()) { + shot->renderSettings.rendererSubtype = renderer->subtype().str(); + changed = true; + } + + if (changed) + project.markDirty(); +} + bool Application::saveProject() { auto &project = m_projectContext.project(); @@ -190,6 +243,8 @@ bool Application::saveProject() bool Application::saveProjectAs(const std::filesystem::path &directory) { + syncActiveShotRenderSettingsFromViewport(); + tsd::core::DataTree scratch; auto &root = scratch.root(); saveWindowSettings(root["windows"]); @@ -223,6 +278,13 @@ bool Application::openProject(const std::filesystem::path &directory) return false; } + if (const auto *shot = activeShot(m_projectContext.project())) { + auto &viewportSettings = scratch.root()["windows"]["Viewport"]; + viewportSettings["anariLibrary"] = shot->renderSettings.rendererLibrary; + viewportSettings["rendererObjectIndex"] = + static_cast(shot->renderSettings.rendererObjectIndex); + } + loadWindowSettings(scratch.root()["windows"]); loadLayout(layout); loadApplicationSettings(scratch.root()); @@ -534,6 +596,8 @@ void Application::uiFrameStart() if (!modalActive && ImGui::IsKeyChordPressed(ImGuiKey_Escape)) appContext()->clearSelected(); + + syncActiveShotRenderSettingsFromViewport(); } void Application::uiMainMenuBar() diff --git a/tsd/apps/interactive/scivisStudio/Application.h b/tsd/apps/interactive/scivisStudio/Application.h index 5749e2a7a..7da005a33 100644 --- a/tsd/apps/interactive/scivisStudio/Application.h +++ b/tsd/apps/interactive/scivisStudio/Application.h @@ -71,6 +71,8 @@ class Application : public tsd::ui::imgui::Application void loadWindowSettings(tsd::core::DataNode &node); std::string saveLayout() const; void loadLayout(const std::string &layout); + void restoreViewportFromActiveShot(); + void syncActiveShotRenderSettingsFromViewport(); void requestDirtyAction(PendingDirtyAction action); void requestOpenRecentProject(const std::filesystem::path &directory); void continueDirtyAction(); diff --git a/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp b/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp index 2c4841fb8..8e355fe37 100644 --- a/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp +++ b/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp @@ -97,6 +97,8 @@ void projectToNode(const Project &project, tsd::core::DataNode &node) 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; @@ -170,6 +172,9 @@ bool nodeToProject(tsd::core::DataNode &node, Project &project) (*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 = diff --git a/tsd/apps/interactive/scivisStudio/RenderShot.cpp b/tsd/apps/interactive/scivisStudio/RenderShot.cpp index a883abded..3865bdf70 100644 --- a/tsd/apps/interactive/scivisStudio/RenderShot.cpp +++ b/tsd/apps/interactive/scivisStudio/RenderShot.cpp @@ -16,6 +16,40 @@ 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) { @@ -46,34 +80,41 @@ bool renderActiveShotToFrames( } auto libName = shot->renderSettings.rendererLibrary; - auto subtype = shot->renderSettings.rendererSubtype.empty() - ? std::string("default") - : shot->renderSettings.rendererSubtype; - - auto library = - anari::loadLibrary(libName.c_str(), tsd::app::anariStatusFunc, nullptr); - if (!library) { - tsd::core::logError( - "[SciVisStudio] Failed to load ANARI library '%s'", libName.c_str()); - return false; - } - - auto device = anari::newDevice(library, "default"); - anari::unloadLibrary(library); + auto device = loadFirstAvailableDevice(ctx->anari, libName); if (!device) { tsd::core::logError( - "[SciVisStudio] Failed to create ANARI device '%s'", libName.c_str()); + "[SciVisStudio] Failed to load an ANARI device for shot rendering"); return false; } - anari::commitParameters(device, device); auto *renderIndex = ctx->tsd.scene.updateDelegate() .emplace( ctx->tsd.scene, libName, device); renderIndex->populate(); - auto renderer = anari::newObject(device, subtype.c_str()); - anari::commitParameters(device, renderer); + 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( @@ -133,7 +174,6 @@ bool renderActiveShotToFrames( projectContext.applyActiveShot(); ctx->tsd.scene.updateDelegate().erase(renderIndex); - anari::release(device, renderer); anari::release(device, device); return true; diff --git a/tsd/apps/interactive/scivisStudio/Shot.h b/tsd/apps/interactive/scivisStudio/Shot.h index 45927d195..5562385b7 100644 --- a/tsd/apps/interactive/scivisStudio/Shot.h +++ b/tsd/apps/interactive/scivisStudio/Shot.h @@ -18,6 +18,7 @@ struct ShotRenderSettings uint32_t height{768}; uint32_t samples{128}; std::string rendererLibrary; + size_t rendererObjectIndex{TSD_INVALID_INDEX}; std::string rendererSubtype{"default"}; std::string outputFilePrefix; }; diff --git a/tsd/src/tsd/ui/imgui/windows/Viewport.cpp b/tsd/src/tsd/ui/imgui/windows/Viewport.cpp index c5e53d5cc..1e0402096 100644 --- a/tsd/src/tsd/ui/imgui/windows/Viewport.cpp +++ b/tsd/src/tsd/ui/imgui/windows/Viewport.cpp @@ -9,6 +9,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 +24,7 @@ #include #include #include +#include namespace tsd::ui::imgui { @@ -47,6 +49,16 @@ bool deviceSupportsExtension(anari::Device d, const char *extension) return false; } +std::string defaultLibraryName(const std::vector &libraryList) +{ + for (const auto &libName : libraryList) { + if (!libName.empty() && libName != "{none}") + return libName; + } + + return {}; +} + } // namespace Viewport::Viewport( @@ -120,7 +132,7 @@ void Viewport::buildUI() } } -void Viewport::setLibrary(const std::string &libName) +void Viewport::setLibrary(const std::string &libName, size_t rendererIndex) { teardownDevice(); @@ -130,13 +142,30 @@ void Viewport::setLibrary(const std::string &libName) libName.c_str()); } - auto updateLibrary = [&, libName = libName]() { + auto updateLibrary = [&, libName = libName, rendererIndex = rendererIndex]() { auto &adm = appContext()->anari; 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 && !selectedLibName.empty() && selectedLibName != "{none}") { + 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.libraryList()); + 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 +183,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; @@ -221,6 +265,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 +313,8 @@ void Viewport::refreshCurrentDevice() void Viewport::saveSettings(tsd::core::DataNode &root) { root["anariLibrary"] = m_libName; + root["rendererObjectIndex"] = + static_cast(currentRendererObjectIndex()); // Viewport settings // @@ -324,7 +380,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); } } diff --git a/tsd/src/tsd/ui/imgui/windows/Viewport.h b/tsd/src/tsd/ui/imgui/windows/Viewport.h index 2f9ea833c..ea474be1d 100644 --- a/tsd/src/tsd/ui/imgui/windows/Viewport.h +++ b/tsd/src/tsd/ui/imgui/windows/Viewport.h @@ -41,8 +41,11 @@ 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); diff --git a/tsd/tests/test_SciVisStudio.cpp b/tsd/tests/test_SciVisStudio.cpp index 6ae852270..28093531f 100644 --- a/tsd/tests/test_SciVisStudio.cpp +++ b/tsd/tests/test_SciVisStudio.cpp @@ -63,6 +63,9 @@ SCENARIO("SciVis Studio project model serialization", "[SciVisStudio]") shot.datasetBindings.push_back({"dataset_0001", true}); shot.lightGroup = {"studio", 5}; 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"; @@ -90,6 +93,11 @@ SCENARIO("SciVis Studio project model serialization", "[SciVisStudio]") REQUIRE(loaded.datasets.front().id == "dataset_0001"); REQUIRE(loaded.shots.size() == 1); REQUIRE(loaded.shots.front().id == "shot_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 From cdbe5d7b620fed48a8e9278c81613abc2701ca28 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Thu, 21 May 2026 09:29:32 -0500 Subject: [PATCH 24/49] make device/renderer selection for shot rendering are dropdown menus --- .../scivisStudio/windows/ShotEditor.cpp | 130 +++++++++++++++++- .../scivisStudio/windows/ShotEditor.h | 4 + tsd/src/tsd/app/ANARIDeviceManager.cpp | 8 +- tsd/src/tsd/app/ANARIDeviceManager.h | 1 + tsd/src/tsd/ui/imgui/windows/Viewport.cpp | 16 ++- 5 files changed, 147 insertions(+), 12 deletions(-) diff --git a/tsd/apps/interactive/scivisStudio/windows/ShotEditor.cpp b/tsd/apps/interactive/scivisStudio/windows/ShotEditor.cpp index e1c8fbb9a..aabc8e8cd 100644 --- a/tsd/apps/interactive/scivisStudio/windows/ShotEditor.cpp +++ b/tsd/apps/interactive/scivisStudio/windows/ShotEditor.cpp @@ -4,13 +4,32 @@ #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) @@ -33,6 +52,111 @@ bool ShotEditor::inputText( 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() { if (!m_projectContext) @@ -106,10 +230,8 @@ void ShotEditor::buildUI() shot->renderSettings.samples = static_cast(std::max(1, samples)); project.markDirty(); } - if (inputText("Renderer library", shot->renderSettings.rendererLibrary)) - project.markDirty(); - if (inputText("Renderer subtype", shot->renderSettings.rendererSubtype)) - project.markDirty(); + buildUI_deviceSelector(*shot); + buildUI_rendererSelector(*shot); if (inputText("Output prefix", shot->renderSettings.outputFilePrefix)) project.markDirty(); diff --git a/tsd/apps/interactive/scivisStudio/windows/ShotEditor.h b/tsd/apps/interactive/scivisStudio/windows/ShotEditor.h index 6f54186ba..dadf2b600 100644 --- a/tsd/apps/interactive/scivisStudio/windows/ShotEditor.h +++ b/tsd/apps/interactive/scivisStudio/windows/ShotEditor.h @@ -7,6 +7,7 @@ #include "tsd/ui/imgui/windows/Window.h" #include +#include namespace tsd::scivis_studio { @@ -21,9 +22,12 @@ struct ShotEditor : public tsd::ui::imgui::Window private: bool inputText(const char *label, std::string &value, size_t capacity = 512); + void buildUI_deviceSelector(Shot &shot); + void buildUI_rendererSelector(Shot &shot); ProjectContext *m_projectContext{nullptr}; std::function m_onRender; + std::string m_rendererLoadAttemptedLibrary; }; } // namespace tsd::scivis_studio 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/ui/imgui/windows/Viewport.cpp b/tsd/src/tsd/ui/imgui/windows/Viewport.cpp index 1e0402096..f6062d464 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" @@ -49,10 +51,10 @@ bool deviceSupportsExtension(anari::Device d, const char *extension) return false; } -std::string defaultLibraryName(const std::vector &libraryList) +std::string defaultLibraryName(const tsd::app::ANARIDeviceManager &adm) { - for (const auto &libName : libraryList) { - if (!libName.empty() && libName != "{none}") + for (const auto &libName : adm.libraryList()) { + if (adm.isLoadableLibrary(libName)) return libName; } @@ -136,21 +138,21 @@ 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, rendererIndex = rendererIndex]() { - auto &adm = appContext()->anari; auto &scene = appContext()->tsd.scene; auto start = std::chrono::steady_clock::now(); auto selectedLibName = libName; auto d = adm.loadDevice(selectedLibName); - if (!d && !selectedLibName.empty() && selectedLibName != "{none}") { + if (!d && adm.isLoadableLibrary(selectedLibName)) { tsd::core::logWarning( "[viewport] failed to load ANARI device '%s'; falling back to a " "default device", @@ -158,7 +160,7 @@ void Viewport::setLibrary(const std::string &libName, size_t rendererIndex) } if (!d) { - const auto fallbackLibName = defaultLibraryName(adm.libraryList()); + const auto fallbackLibName = defaultLibraryName(adm); if (!fallbackLibName.empty() && fallbackLibName != selectedLibName) { selectedLibName = fallbackLibName; d = adm.loadDevice(selectedLibName); From ba4e81d65135ebaa70097344abe4fc42ed32849a Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Thu, 21 May 2026 09:55:09 -0500 Subject: [PATCH 25/49] implement offline render task cancellation via the UI --- .../interactive/scivisStudio/Application.cpp | 5 ++-- .../interactive/scivisStudio/RenderShot.cpp | 8 ++++++- tsd/src/tsd/ui/imgui/Application.h | 24 +++++++++++++++++++ .../tsd/ui/imgui/modals/BlockingTaskModal.cpp | 23 +++++++++++++++++- .../tsd/ui/imgui/modals/BlockingTaskModal.h | 10 +++++++- tsd/src/tsd/ui/imgui/modals/Modal.cpp | 8 ++++++- tsd/src/tsd/ui/imgui/modals/Modal.h | 1 + 7 files changed, 73 insertions(+), 6 deletions(-) diff --git a/tsd/apps/interactive/scivisStudio/Application.cpp b/tsd/apps/interactive/scivisStudio/Application.cpp index a47961b43..2ee41b353 100644 --- a/tsd/apps/interactive/scivisStudio/Application.cpp +++ b/tsd/apps/interactive/scivisStudio/Application.cpp @@ -515,9 +515,10 @@ void Application::renderActiveShot() return; } - showTaskModal( - [this]() { + showTaskModalWithCancel( + [this](const std::atomic_bool &cancelRequested) { RenderShotProgress progress; + progress.onFrame = [&](int, int) { return !cancelRequested.load(); }; renderActiveShotToFrames(m_projectContext, &progress); }, "Rendering Active Shot..."); diff --git a/tsd/apps/interactive/scivisStudio/RenderShot.cpp b/tsd/apps/interactive/scivisStudio/RenderShot.cpp index 3865bdf70..516d89f68 100644 --- a/tsd/apps/interactive/scivisStudio/RenderShot.cpp +++ b/tsd/apps/interactive/scivisStudio/RenderShot.cpp @@ -153,8 +153,14 @@ bool renderActiveShotToFrames( outputDirectory.string().c_str()); for (int frame = 0; frame < totalFrames; ++frame) { - if (progress && progress->onFrame && !progress->onFrame(frame, totalFrames)) + if (progress && progress->onFrame + && !progress->onFrame(frame, totalFrames)) { + tsd::core::logStatus( + "[SciVisStudio] Shot render canceled before frame %d/%d", + frame, + totalFrames); break; + } ctx->tsd.animationMgr.setAnimationFrame(frame); diff --git a/tsd/src/tsd/ui/imgui/Application.h b/tsd/src/tsd/ui/imgui/Application.h index 2d2205526..6caac9d29 100644 --- a/tsd/src/tsd/ui/imgui/Application.h +++ b/tsd/src/tsd/ui/imgui/Application.h @@ -20,6 +20,7 @@ // SDL #include // std +#include #include #include #include @@ -78,6 +79,10 @@ 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 saveDefaultApplicationSettings(); @@ -217,4 +222,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/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}; From 98fe7672191e6d7ebe874af08f7f006032ae4f2f Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Thu, 21 May 2026 10:02:12 -0500 Subject: [PATCH 26/49] temporarily disable viewport updates when rendering a shot --- .../interactive/scivisStudio/Application.cpp | 18 ++++++++++++++++++ .../interactive/scivisStudio/Application.h | 1 + tsd/src/tsd/ui/imgui/windows/Viewport.cpp | 11 ++++++++++- tsd/src/tsd/ui/imgui/windows/Viewport.h | 2 ++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/tsd/apps/interactive/scivisStudio/Application.cpp b/tsd/apps/interactive/scivisStudio/Application.cpp index 2ee41b353..cea6b14bd 100644 --- a/tsd/apps/interactive/scivisStudio/Application.cpp +++ b/tsd/apps/interactive/scivisStudio/Application.cpp @@ -515,6 +515,11 @@ void Application::renderActiveShot() return; } + if (m_viewport) { + m_viewport->setRenderingEnabled(false); + m_viewportRenderingDisabledForShotRender = true; + } + showTaskModalWithCancel( [this](const std::atomic_bool &cancelRequested) { RenderShotProgress progress; @@ -522,6 +527,12 @@ void Application::renderActiveShot() 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 @@ -570,6 +581,13 @@ void Application::uiFrameStart() 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; diff --git a/tsd/apps/interactive/scivisStudio/Application.h b/tsd/apps/interactive/scivisStudio/Application.h index 7da005a33..fe0139f38 100644 --- a/tsd/apps/interactive/scivisStudio/Application.h +++ b/tsd/apps/interactive/scivisStudio/Application.h @@ -89,6 +89,7 @@ class Application : public tsd::ui::imgui::Application std::filesystem::path m_pendingProjectDirectory; std::vector m_recentProjects; PendingDirtyAction m_pendingDirtyAction{PendingDirtyAction::None}; + bool m_viewportRenderingDisabledForShotRender{false}; tsd::ui::imgui::Viewport *m_viewport{nullptr}; tsd::ui::imgui::LayerTree *m_layerTree{nullptr}; diff --git a/tsd/src/tsd/ui/imgui/windows/Viewport.cpp b/tsd/src/tsd/ui/imgui/windows/Viewport.cpp index f6062d464..1bfc72530 100644 --- a/tsd/src/tsd/ui/imgui/windows/Viewport.cpp +++ b/tsd/src/tsd/ui/imgui/windows/Viewport.cpp @@ -241,7 +241,8 @@ void Viewport::setLibrary(const std::string &libName, size_t rendererIndex) 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"); @@ -395,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(); @@ -516,6 +518,13 @@ 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::camera_resetView(bool resetAzEl) { const auto mode = m_camera.arcball->mode(); diff --git a/tsd/src/tsd/ui/imgui/windows/Viewport.h b/tsd/src/tsd/ui/imgui/windows/Viewport.h index ea474be1d..c9dc16153 100644 --- a/tsd/src/tsd/ui/imgui/windows/Viewport.h +++ b/tsd/src/tsd/ui/imgui/windows/Viewport.h @@ -50,6 +50,7 @@ struct Viewport : public BaseViewport 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); private: void refreshCurrentDevice(); @@ -96,6 +97,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}; From 8194b26f880fb488944813c31725500ef1bae4b2 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Thu, 21 May 2026 19:28:42 -0500 Subject: [PATCH 27/49] fix orphan Scene object references after project load --- .../interactive/scivisStudio/Application.cpp | 12 ++++++++++ tsd/src/tsd/ui/imgui/windows/Viewport.cpp | 23 +++++++++++++------ tsd/src/tsd/ui/imgui/windows/Viewport.h | 1 + 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/tsd/apps/interactive/scivisStudio/Application.cpp b/tsd/apps/interactive/scivisStudio/Application.cpp index cea6b14bd..02034500c 100644 --- a/tsd/apps/interactive/scivisStudio/Application.cpp +++ b/tsd/apps/interactive/scivisStudio/Application.cpp @@ -265,6 +265,9 @@ bool Application::saveProjectAs(const std::filesystem::path &directory) bool Application::openProject(const std::filesystem::path &directory) { + if (m_viewport) + m_viewport->releaseSceneReferences(); + tsd::core::DataTree scratch; std::string layout; std::string error; @@ -275,6 +278,7 @@ bool Application::openProject(const std::filesystem::path &directory) &error); if (!ok) { tsd::core::logError("[SciVisStudio] Open failed: %s", error.c_str()); + restoreViewportFromActiveShot(); return false; } @@ -294,12 +298,20 @@ bool Application::openProject(const std::filesystem::path &directory) void Application::newProject() { + if (m_viewport) + m_viewport->releaseSceneReferences(); + m_projectContext.createUnsavedProject(); + restoreViewportFromActiveShot(); } void Application::closeProject() { + if (m_viewport) + m_viewport->releaseSceneReferences(); + m_projectContext.createUnsavedProject(); + restoreViewportFromActiveShot(); } void Application::requestDirtyAction(PendingDirtyAction action) diff --git a/tsd/src/tsd/ui/imgui/windows/Viewport.cpp b/tsd/src/tsd/ui/imgui/windows/Viewport.cpp index 1bfc72530..1ed76d104 100644 --- a/tsd/src/tsd/ui/imgui/windows/Viewport.cpp +++ b/tsd/src/tsd/ui/imgui/windows/Viewport.cpp @@ -525,6 +525,11 @@ void Viewport::setRenderingEnabled(bool enabled) m_anariPass->setEnabled(enabled); } +void Viewport::releaseSceneReferences() +{ + teardownDevice(); +} + void Viewport::camera_resetView(bool resetAzEl) { const auto mode = m_camera.arcball->mode(); @@ -603,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; @@ -621,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 c9dc16153..3fa204456 100644 --- a/tsd/src/tsd/ui/imgui/windows/Viewport.h +++ b/tsd/src/tsd/ui/imgui/windows/Viewport.h @@ -51,6 +51,7 @@ struct Viewport : public BaseViewport 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(); From a53f099134b8a4f8e4d3b5f10f13a1471c4a27c7 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Thu, 21 May 2026 21:06:35 -0500 Subject: [PATCH 28/49] fix fragile volume import conditions causing crashes in some cases --- tsd/src/tsd/io/importers/import_file.cpp | 32 +++++++++-- tsd/src/tsd/io/importers/import_volume.cpp | 25 ++++---- tsd/tests/CMakeLists.txt | 2 + tsd/tests/test_Importers.cpp | 66 ++++++++++++++++++++++ 4 files changed, 107 insertions(+), 18 deletions(-) create mode 100644 tsd/tests/test_Importers.cpp 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/tests/CMakeLists.txt b/tsd/tests/CMakeLists.txt index dda67a257..51dd2301b 100644 --- a/tsd/tests/CMakeLists.txt +++ b/tsd/tests/CMakeLists.txt @@ -15,6 +15,7 @@ project_add_executable( test_FlatMap.cpp test_Forest.cpp test_Geometry.cpp + test_Importers.cpp test_Manipulator.cpp test_ObjectPool.cpp test_Material.cpp @@ -49,6 +50,7 @@ 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]" ) 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); +} From be0be9f923503ad58a9fe466105c5bed2a05c33e Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 22 May 2026 09:14:07 -0500 Subject: [PATCH 29/49] add code to export only Scene cameras and renderers --- tsd/src/tsd/io/serialization.hpp | 4 + .../serialization/serialization_datatree.cpp | 129 +++++++++++++---- tsd/src/tsd/scene/Scene.hpp | 6 + tsd/tests/CMakeLists.txt | 2 + tsd/tests/test_Serialization.cpp | 130 ++++++++++++++++++ 5 files changed, 243 insertions(+), 28 deletions(-) create mode 100644 tsd/tests/test_Serialization.cpp diff --git a/tsd/src/tsd/io/serialization.hpp b/tsd/src/tsd/io/serialization.hpp index 0fa9d4705..752bf6fd8 100644 --- a/tsd/src/tsd/io/serialization.hpp +++ b/tsd/src/tsd/io/serialization.hpp @@ -68,6 +68,10 @@ 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); +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); 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 3a62067ed..b1209ec5a 100644 --- a/tsd/src/tsd/io/serialization/serialization_datatree.cpp +++ b/tsd/src/tsd/io/serialization/serialization_datatree.cpp @@ -24,6 +24,27 @@ namespace tsd::io { +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) @@ -552,34 +573,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 // @@ -587,6 +590,27 @@ 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(); + 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) @@ -667,4 +691,53 @@ void load_Scene(Scene &scene, tsd::core::logStatus(" ...done!"); } +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; + } + + auto &root = tree.root(); + if (auto *c = root.child("context"); c != nullptr) + load_SceneCamerasAndRenderers(scene, *c); + else + load_SceneCamerasAndRenderers(scene, root); +} + +void load_SceneCamerasAndRenderers(Scene &scene, core::DataNode &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 = root["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!"); +} + } // namespace tsd::io diff --git a/tsd/src/tsd/scene/Scene.hpp b/tsd/src/tsd/scene/Scene.hpp index b36231fdf..bde80af81 100644 --- a/tsd/src/tsd/scene/Scene.hpp +++ b/tsd/src/tsd/scene/Scene.hpp @@ -33,6 +33,8 @@ namespace tsd::io { // clang-format off void save_Scene(scene::Scene &, core::DataNode &, bool, animation::AnimationManager *); void load_Scene(scene::Scene &, core::DataNode &, animation::AnimationManager *); +void save_SceneCamerasAndRenderers(scene::Scene &, core::DataNode &); +void load_SceneCamerasAndRenderers(scene::Scene &, core::DataNode &); // clang-format on } // namespace tsd::io @@ -234,6 +236,10 @@ struct Scene Scene &, core::DataNode &, bool, tsd::animation::AnimationManager *); friend void ::tsd::io::load_Scene( Scene &, core::DataNode &, tsd::animation::AnimationManager *); + friend void ::tsd::io::save_SceneCamerasAndRenderers( + Scene &, core::DataNode &); + friend void ::tsd::io::load_SceneCamerasAndRenderers( + Scene &, core::DataNode &); template ObjectPoolRef createObjectImpl(ObjectPool &iv, Args &&...args); diff --git a/tsd/tests/CMakeLists.txt b/tsd/tests/CMakeLists.txt index 51dd2301b..dcb2f7a5a 100644 --- a/tsd/tests/CMakeLists.txt +++ b/tsd/tests/CMakeLists.txt @@ -24,6 +24,7 @@ project_add_executable( test_ObjectUsePtr.cpp test_Parameter.cpp test_Scene.cpp + test_Serialization.cpp test_Token.cpp ) if (TARGET tsd_scivis_studio_model) @@ -59,6 +60,7 @@ 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]") diff --git a/tsd/tests/test_Serialization.cpp b/tsd/tests/test_Serialization.cpp new file mode 100644 index 000000000..304b5e7a5 --- /dev/null +++ b/tsd/tests/test_Serialization.cpp @@ -0,0 +1,130 @@ +// Copyright 2024-2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +// catch +#include "catch.hpp" +// tsd +#include "tsd/core/DataTree.hpp" +#include "tsd/io/serialization.hpp" +#include "tsd/scene/Scene.hpp" + +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 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); + } + } + } +} From fe99059b928250bb9e982dc297cdca4a52c72891 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 22 May 2026 09:57:54 -0500 Subject: [PATCH 30/49] add CLI tool for rendering shots --- .../interactive/scivisStudio/CMakeLists.txt | 12 +- .../interactive/scivisStudio/RenderShot.cpp | 4 +- .../scivisStudio/RenderShotCLI.cpp | 167 ++++++++++++++++++ .../interactive/scivisStudio/RenderShotCLI.h | 39 ++++ .../scivisStudio/scivisStudioRenderShot.cpp | 105 +++++++++++ tsd/tests/test_SciVisStudio.cpp | 81 +++++++++ 6 files changed, 406 insertions(+), 2 deletions(-) create mode 100644 tsd/apps/interactive/scivisStudio/RenderShotCLI.cpp create mode 100644 tsd/apps/interactive/scivisStudio/RenderShotCLI.h create mode 100644 tsd/apps/interactive/scivisStudio/scivisStudioRenderShot.cpp diff --git a/tsd/apps/interactive/scivisStudio/CMakeLists.txt b/tsd/apps/interactive/scivisStudio/CMakeLists.txt index 33d7faec7..a3c4036aa 100644 --- a/tsd/apps/interactive/scivisStudio/CMakeLists.txt +++ b/tsd/apps/interactive/scivisStudio/CMakeLists.txt @@ -6,6 +6,8 @@ add_library(tsd_scivis_studio_model STATIC Project.cpp ProjectContext.cpp ProjectSerialization.cpp + RenderShot.cpp + RenderShotCLI.cpp Shot.cpp ShotCameraRig.cpp ) @@ -38,7 +40,6 @@ configure_file( add_executable(scivisStudio Application.cpp - RenderShot.cpp modals/AddDatasetDialog.cpp modals/ConfirmDiscardDialog.cpp modals/ProjectLocationDialog.cpp @@ -59,3 +60,12 @@ 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/RenderShot.cpp b/tsd/apps/interactive/scivisStudio/RenderShot.cpp index 516d89f68..79b5494e6 100644 --- a/tsd/apps/interactive/scivisStudio/RenderShot.cpp +++ b/tsd/apps/interactive/scivisStudio/RenderShot.cpp @@ -152,6 +152,7 @@ bool renderActiveShotToFrames( totalFrames, outputDirectory.string().c_str()); + bool completed = true; for (int frame = 0; frame < totalFrames; ++frame) { if (progress && progress->onFrame && !progress->onFrame(frame, totalFrames)) { @@ -159,6 +160,7 @@ bool renderActiveShotToFrames( "[SciVisStudio] Shot render canceled before frame %d/%d", frame, totalFrames); + completed = false; break; } @@ -182,7 +184,7 @@ bool renderActiveShotToFrames( ctx->tsd.scene.updateDelegate().erase(renderIndex); anari::release(device, device); - return true; + return completed; } } // 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/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/tests/test_SciVisStudio.cpp b/tsd/tests/test_SciVisStudio.cpp index 28093531f..185de232b 100644 --- a/tsd/tests/test_SciVisStudio.cpp +++ b/tsd/tests/test_SciVisStudio.cpp @@ -5,12 +5,14 @@ #include "ProjectContext.h" #include "ProjectSerialization.h" +#include "RenderShotCLI.h" #include "tsd/app/Context.h" #include "tsd/core/DataTree.hpp" #include "tsd/scene/UpdateDelegate.hpp" #include +#include using namespace tsd::scivis_studio; @@ -312,3 +314,82 @@ SCENARIO("SciVis Studio shot time is driven by the animation manager", 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"); +} From d923fb2bd6180b984bc55415402e9a7a0bdb399c Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 22 May 2026 10:08:23 -0500 Subject: [PATCH 31/49] fix UI issues with camera rig window --- .../scivisStudio/windows/CameraRigEditor.cpp | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp b/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp index ebd8a3e27..a62883d2a 100644 --- a/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp +++ b/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp @@ -90,7 +90,9 @@ void CameraRigEditor::buildUI() ImGui::EndDisabled(); if (ImGui::BeginTable( - "keyframes", 4, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { + "keyframes", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { + ImGui::TableSetupColumn( + "", ImGuiTableColumnFlags_WidthFixed, ImGui::GetFrameHeight()); ImGui::TableSetupColumn("Frame"); ImGui::TableSetupColumn("Name"); ImGui::TableSetupColumn("Interpolation"); @@ -101,12 +103,26 @@ void CameraRigEditor::buildUI() 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::Selectable("##select", - m_selectedKeyframe == i, - ImGuiSelectableFlags_SpanAllColumns)) + if (ImGui::RadioButton("##selected", m_selectedKeyframe == i)) + m_selectedKeyframe = i; + if (ImGui::IsItemHovered() + && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { m_selectedKeyframe = i; - ImGui::SameLine(); + shot->currentFrame = keyframe.frame; + if (ctx) + ctx->tsd.animationMgr.setAnimationFrame(shot->currentFrame); + else + m_projectContext->applyActiveShot(); + } + + ImGui::TableNextColumn(); if (ImGui::InputInt("##frame", &keyframe.frame)) { sortKeyframes(rig); project.markDirty(); @@ -140,6 +156,12 @@ void CameraRigEditor::buildUI() ImGui::EndTable(); } + + if (hasSelection && ImGui::IsWindowHovered() + && ImGui::IsMouseClicked(ImGuiMouseButton_Left) + && !ImGui::IsAnyItemHovered()) { + m_selectedKeyframe = -1; + } } } // namespace tsd::scivis_studio From 7962babe07ec38e5bb7b43e7b487647f7690af26 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 22 May 2026 10:17:35 -0500 Subject: [PATCH 32/49] add new interpolation modes for camera animations --- .../scivisStudio/ShotCameraRig.cpp | 43 +++++++++++-- .../interactive/scivisStudio/ShotCameraRig.h | 5 +- .../scivisStudio/windows/CameraRigEditor.cpp | 52 ++++++++++++--- tsd/tests/test_SciVisStudio.cpp | 63 +++++++++++++++++-- 4 files changed, 143 insertions(+), 20 deletions(-) diff --git a/tsd/apps/interactive/scivisStudio/ShotCameraRig.cpp b/tsd/apps/interactive/scivisStudio/ShotCameraRig.cpp index 70ba66f3f..e1c9765b0 100644 --- a/tsd/apps/interactive/scivisStudio/ShotCameraRig.cpp +++ b/tsd/apps/interactive/scivisStudio/ShotCameraRig.cpp @@ -17,6 +17,12 @@ const char *toString(CameraInterpolation interpolation) 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"; } @@ -25,6 +31,12 @@ CameraInterpolation cameraInterpolationFromString(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; } @@ -69,6 +81,22 @@ static tsd::math::float3 lerpVec3( 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); + case CameraInterpolation::EaseOutIn: + return t * t * (3.f - 2.f * t); + } + return t; +} + ManipulatorState sampleCameraRig(const ShotCameraRig &rig, int frame) { if (rig.keyframes.empty()) @@ -99,15 +127,18 @@ ManipulatorState sampleCameraRig(const ShotCameraRig &rig, int frame) 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(t, a.manipulator.orbit.lookat, b.manipulator.orbit.lookat); - out.orbit.azeldist = tsd::rendering::lerpAzElDist( - t, a.manipulator.orbit.azeldist, b.manipulator.orbit.azeldist); - out.orbit.fixedDist = - lerp(t, a.manipulator.orbit.fixedDist, b.manipulator.orbit.fixedDist); + 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; diff --git a/tsd/apps/interactive/scivisStudio/ShotCameraRig.h b/tsd/apps/interactive/scivisStudio/ShotCameraRig.h index c41aba022..c373d9e4c 100644 --- a/tsd/apps/interactive/scivisStudio/ShotCameraRig.h +++ b/tsd/apps/interactive/scivisStudio/ShotCameraRig.h @@ -13,7 +13,10 @@ namespace tsd::scivis_studio { enum class CameraInterpolation { Hold, - Linear + Linear, + EaseOut, + EaseIn, + EaseOutIn }; struct ManipulatorState diff --git a/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp b/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp index a62883d2a..ef32d3dee 100644 --- a/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp +++ b/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp @@ -13,6 +13,43 @@ 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) @@ -104,8 +141,7 @@ void CameraRigEditor::buildUI() ImGui::PushID(i); ImGui::TableNextRow(); if (m_selectedKeyframe == i) { - const ImU32 selectedColor = - ImGui::GetColorU32(ImGuiCol_Header); + const ImU32 selectedColor = ImGui::GetColorU32(ImGuiCol_Header); ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, selectedColor); } @@ -138,12 +174,12 @@ void CameraRigEditor::buildUI() ImGui::TableNextColumn(); int interpolation = - keyframe.interpolationToNext == CameraInterpolation::Hold ? 0 : 1; - const char *items[] = {"Hold", "Linear"}; - if (ImGui::Combo("##interp", &interpolation, items, 2)) { - keyframe.interpolationToNext = interpolation == 0 - ? CameraInterpolation::Hold - : CameraInterpolation::Linear; + 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(); } diff --git a/tsd/tests/test_SciVisStudio.cpp b/tsd/tests/test_SciVisStudio.cpp index 185de232b..463163c56 100644 --- a/tsd/tests/test_SciVisStudio.cpp +++ b/tsd/tests/test_SciVisStudio.cpp @@ -73,7 +73,7 @@ SCENARIO("SciVis Studio project model serialization", "[SciVisStudio]") 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::Hold; + keyframe.interpolationToNext = CameraInterpolation::EaseOutIn; shot.cameraRig.keyframes.push_back(keyframe); project.activeShotId = shot.id; project.shots.push_back(shot); @@ -102,8 +102,61 @@ SCENARIO("SciVis Studio project model serialization", "[SciVisStudio]") == "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::Hold); + REQUIRE( + loaded.shots.front().cameraRig.keyframes.front().interpolationToNext + == CameraInterpolation::EaseOutIn); + } + } +} + +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(cameraInterpolationFromString(toString(mode)) == mode); + + REQUIRE(cameraInterpolationFromString("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(sampleCameraRig(rig, 25).orbit.lookat.x == Approx(6.25f)); + + rig.keyframes.front().interpolationToNext = CameraInterpolation::EaseIn; + REQUIRE(sampleCameraRig(rig, 25).orbit.lookat.x == Approx(43.75f)); + + rig.keyframes.front().interpolationToNext = + CameraInterpolation::EaseOutIn; + REQUIRE(sampleCameraRig(rig, 25).orbit.lookat.x == Approx(15.625f)); + REQUIRE(sampleCameraRig(rig, 25).orbit.azeldist.x == Approx(15.625f)); + REQUIRE(sampleCameraRig(rig, 25).orbit.fixedDist == Approx(15.625f)); } } } @@ -236,8 +289,8 @@ SCENARIO("SciVis Studio dataset binding resolves the dataset group by ID", 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"; + const auto root = + std::filesystem::temp_directory_path() / "tsd_scivis_studio_runtime_refs"; std::filesystem::remove_all(root); { From 372b69febde9906fd8a5b1a2d308f9bd6403fa2e Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 22 May 2026 10:21:47 -0500 Subject: [PATCH 33/49] adjust interpolation curves --- tsd/apps/interactive/scivisStudio/ShotCameraRig.cpp | 4 ++-- tsd/tests/test_SciVisStudio.cpp | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/tsd/apps/interactive/scivisStudio/ShotCameraRig.cpp b/tsd/apps/interactive/scivisStudio/ShotCameraRig.cpp index e1c9765b0..95e565000 100644 --- a/tsd/apps/interactive/scivisStudio/ShotCameraRig.cpp +++ b/tsd/apps/interactive/scivisStudio/ShotCameraRig.cpp @@ -90,9 +90,9 @@ static float applyInterpolation(CameraInterpolation interpolation, float t) case CameraInterpolation::EaseOut: return t * t; case CameraInterpolation::EaseIn: - return 1.f - (1.f - t) * (1.f - t); + return 1.f - (1.f - t) * (1.f - t) * (1.f - t); case CameraInterpolation::EaseOutIn: - return t * t * (3.f - 2.f * t); + return t * t * t * (t * (6.f * t - 15.f) + 10.f); } return t; } diff --git a/tsd/tests/test_SciVisStudio.cpp b/tsd/tests/test_SciVisStudio.cpp index 463163c56..402f54a8c 100644 --- a/tsd/tests/test_SciVisStudio.cpp +++ b/tsd/tests/test_SciVisStudio.cpp @@ -150,13 +150,14 @@ SCENARIO("SciVis Studio camera interpolation modes", "[SciVisStudio]") REQUIRE(sampleCameraRig(rig, 25).orbit.lookat.x == Approx(6.25f)); rig.keyframes.front().interpolationToNext = CameraInterpolation::EaseIn; - REQUIRE(sampleCameraRig(rig, 25).orbit.lookat.x == Approx(43.75f)); + REQUIRE(sampleCameraRig(rig, 25).orbit.lookat.x == Approx(57.8125f)); rig.keyframes.front().interpolationToNext = CameraInterpolation::EaseOutIn; - REQUIRE(sampleCameraRig(rig, 25).orbit.lookat.x == Approx(15.625f)); - REQUIRE(sampleCameraRig(rig, 25).orbit.azeldist.x == Approx(15.625f)); - REQUIRE(sampleCameraRig(rig, 25).orbit.fixedDist == Approx(15.625f)); + REQUIRE(sampleCameraRig(rig, 25).orbit.lookat.x == Approx(10.3515625f)); + REQUIRE(sampleCameraRig(rig, 25).orbit.azeldist.x == Approx(10.3515625f)); + REQUIRE(sampleCameraRig(rig, 25).orbit.fixedDist == Approx(10.3515625f)); + REQUIRE(sampleCameraRig(rig, 75).orbit.lookat.x == Approx(89.6484375f)); } } } From 744bc6443ba523b1e003748c505dd25ad103fb49 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 22 May 2026 11:32:00 -0500 Subject: [PATCH 34/49] add tooltip to camera keyframe selector --- tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp b/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp index ef32d3dee..c30f61f7f 100644 --- a/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp +++ b/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp @@ -148,6 +148,8 @@ void CameraRigEditor::buildUI() 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; From 86ebff892e5ecca12fa3f1cbfc0a7ef368cec4f5 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 22 May 2026 13:23:34 -0500 Subject: [PATCH 35/49] add schema metadata validation for DataTree file I/O --- .../scivisStudio/ProjectContext.cpp | 8 +- .../scivisStudio/ProjectSerialization.cpp | 41 +++- .../scivisStudio/ProjectSerialization.h | 2 + tsd/apps/interactive/viewer/tsdViewer.cpp | 54 +++++ tsd/src/tsd/core/DataTreeMetadata.hpp | 127 +++++++++++ tsd/src/tsd/io/serialization.hpp | 45 ++++ .../serialization/serialization_datatree.cpp | 197 ++++++++++++++++-- tsd/src/tsd/scene/Scene.hpp | 10 + tsd/src/tsd/ui/imgui/Application.cpp | 22 ++ tsd/src/tsd/ui/imgui/Application.h | 6 + tsd/tests/test_DataTree.cpp | 80 +++++++ tsd/tests/test_SciVisStudio.cpp | 48 ++++- tsd/tests/test_Serialization.cpp | 105 ++++++++++ 13 files changed, 719 insertions(+), 26 deletions(-) create mode 100644 tsd/src/tsd/core/DataTreeMetadata.hpp diff --git a/tsd/apps/interactive/scivisStudio/ProjectContext.cpp b/tsd/apps/interactive/scivisStudio/ProjectContext.cpp index 600c73447..3d904607d 100644 --- a/tsd/apps/interactive/scivisStudio/ProjectContext.cpp +++ b/tsd/apps/interactive/scivisStudio/ProjectContext.cpp @@ -5,6 +5,7 @@ #include "ProjectSerialization.h" +#include "tsd/core/DataTreeMetadata.hpp" #include "tsd/core/Logging.hpp" #include "tsd/io/serialization.hpp" #include "tsd/rendering/view/ManipulatorToTSD.hpp" @@ -495,8 +496,11 @@ bool ProjectContext::saveProject(const std::filesystem::path &directory, tsd::core::DataTree tree; auto &root = tree.root(); - root["projectKind"] = PROJECT_KIND; - root["schemaVersion"] = SCHEMA_VERSION; + 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); diff --git a/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp b/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp index 8e355fe37..3c788bb02 100644 --- a/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp +++ b/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp @@ -3,6 +3,7 @@ #include "ProjectSerialization.h" +#include "tsd/core/DataTreeMetadata.hpp" #include "tsd/io/serialization.hpp" #include @@ -235,16 +236,42 @@ ProjectValidationResult validateProjectRoot( } auto &root = tree.root(); - const auto kind = root["projectKind"].getValueOr(""); - if (kind != PROJECT_KIND) { - result.error = "projectKind is not SciVisStudio"; + auto metadataResult = tsd::core::readDataTreeMetadata(root); + if (metadataResult.malformed()) { + result.error = "malformed __tsd_metadata: " + metadataResult.message; return result; } - const auto version = root["schemaVersion"].getValueOr(0); - if (version != SCHEMA_VERSION) { - result.error = "unsupported SciVis Studio schemaVersion"; - 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 != 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 != SCHEMA_VERSION) { + result.error = "unsupported legacy SciVis Studio schemaVersion"; + return result; + } } result.ok = true; diff --git a/tsd/apps/interactive/scivisStudio/ProjectSerialization.h b/tsd/apps/interactive/scivisStudio/ProjectSerialization.h index 30e6a78f3..4a4032bb3 100644 --- a/tsd/apps/interactive/scivisStudio/ProjectSerialization.h +++ b/tsd/apps/interactive/scivisStudio/ProjectSerialization.h @@ -13,6 +13,8 @@ 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 = 1; constexpr const char *PROJECT_MANIFEST_FILENAME = "project.tsd"; 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/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/io/serialization.hpp b/tsd/src/tsd/io/serialization.hpp index 752bf6fd8..bc69ceed1 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,44 @@ 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"; + +} // 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,10 +109,14 @@ 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); 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 b1209ec5a..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,111 @@ 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, @@ -555,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 // @@ -604,6 +718,11 @@ void save_SceneCamerasAndRenderers(Scene &scene, const char *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"]; @@ -618,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"); @@ -642,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]; @@ -664,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); @@ -686,9 +829,10 @@ 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) @@ -702,15 +846,37 @@ void load_SceneCamerasAndRenderers(Scene &scene, const char *filename) return; } - auto &root = tree.root(); - if (auto *c = root.child("context"); c != nullptr) - load_SceneCamerasAndRenderers(scene, *c); - else - load_SceneCamerasAndRenderers(scene, root); + 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); @@ -723,7 +889,7 @@ void load_SceneCamerasAndRenderers(Scene &scene, core::DataNode &root) removeObjects(scene.m_db.renderer); removeObjects(scene.m_db.camera); - auto &objectDB = root["objectDB"]; + auto &objectDB = payloadRoot["objectDB"]; auto nodeToObjectPool = [](core::DataNode &node, Scene &scene, const char *childNodeName) { @@ -738,6 +904,7 @@ void load_SceneCamerasAndRenderers(Scene &scene, core::DataNode &root) scene.defaultCamera(); tsd::core::logStatus(" ...done!"); + return true; } } // namespace tsd::io diff --git a/tsd/src/tsd/scene/Scene.hpp b/tsd/src/tsd/scene/Scene.hpp index bde80af81..32120f98e 100644 --- a/tsd/src/tsd/scene/Scene.hpp +++ b/tsd/src/tsd/scene/Scene.hpp @@ -30,11 +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 @@ -236,10 +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 b655ff1eb..2ba1a15b2 100644 --- a/tsd/src/tsd/ui/imgui/Application.cpp +++ b/tsd/src/tsd/ui/imgui/Application.cpp @@ -706,6 +706,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"]; @@ -753,6 +754,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 @@ -763,6 +780,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 6caac9d29..b81ae2d11 100644 --- a/tsd/src/tsd/ui/imgui/Application.h +++ b/tsd/src/tsd/ui/imgui/Application.h @@ -15,6 +15,7 @@ // 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 @@ -126,6 +127,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(); 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_SciVisStudio.cpp b/tsd/tests/test_SciVisStudio.cpp index 402f54a8c..d8d2a5009 100644 --- a/tsd/tests/test_SciVisStudio.cpp +++ b/tsd/tests/test_SciVisStudio.cpp @@ -9,6 +9,7 @@ #include "tsd/app/Context.h" #include "tsd/core/DataTree.hpp" +#include "tsd/core/DataTreeMetadata.hpp" #include "tsd/scene/UpdateDelegate.hpp" #include @@ -169,7 +170,24 @@ SCENARIO("SciVis Studio project root validation", "[SciVisStudio]") std::filesystem::remove_all(root); std::filesystem::create_directories(root); - GIVEN("A valid project manifest") + 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; @@ -183,7 +201,24 @@ SCENARIO("SciVis Studio project root validation", "[SciVisStudio]") } } - GIVEN("An invalid project kind") + 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"; @@ -323,6 +358,15 @@ SCENARIO("SciVis Studio saved projects rebuild runtime refs from stable IDs", { 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("lightGroup") == nullptr); diff --git a/tsd/tests/test_Serialization.cpp b/tsd/tests/test_Serialization.cpp index 304b5e7a5..a1d30a9e8 100644 --- a/tsd/tests/test_Serialization.cpp +++ b/tsd/tests/test_Serialization.cpp @@ -4,6 +4,7 @@ // 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" @@ -40,6 +41,15 @@ SCENARIO("tsd::io camera and renderer subset serialization", "[Serialization]") { 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); @@ -128,3 +138,98 @@ SCENARIO("tsd::io camera and renderer subset serialization", "[Serialization]") } } } + +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); + } + } + } +} From 002039f03640a79abac768a37aefd35e70eb39f8 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 26 May 2026 21:12:00 -0600 Subject: [PATCH 36/49] add light rigs + UI editor --- .../interactive/scivisStudio/Application.cpp | 7 +- .../interactive/scivisStudio/Application.h | 1 + .../interactive/scivisStudio/CMakeLists.txt | 4 + tsd/apps/interactive/scivisStudio/Dataset.h | 1 + tsd/apps/interactive/scivisStudio/Project.cpp | 21 ++ tsd/apps/interactive/scivisStudio/Project.h | 11 + .../scivisStudio/ProjectContext.cpp | 229 +++++++++++--- .../interactive/scivisStudio/ProjectContext.h | 11 +- .../scivisStudio/ProjectSerialization.cpp | 20 +- .../scivisStudio/ProjectSerialization.h | 2 +- .../interactive/scivisStudio/RenderShot.cpp | 5 +- tsd/apps/interactive/scivisStudio/Shot.h | 2 +- .../scivisStudio/default_ui_layout.txt | 40 ++- .../scivisStudio/windows/LightRigEditor.cpp | 294 ++++++++++++++++++ .../scivisStudio/windows/LightRigEditor.h | 33 ++ .../scivisStudio/windows/ShotEditor.cpp | 45 +++ .../scivisStudio/windows/ShotEditor.h | 1 + tsd/tests/test_SciVisStudio.cpp | 193 +++++++++++- 18 files changed, 844 insertions(+), 76 deletions(-) create mode 100644 tsd/apps/interactive/scivisStudio/windows/LightRigEditor.cpp create mode 100644 tsd/apps/interactive/scivisStudio/windows/LightRigEditor.h diff --git a/tsd/apps/interactive/scivisStudio/Application.cpp b/tsd/apps/interactive/scivisStudio/Application.cpp index 02034500c..ee06cf41f 100644 --- a/tsd/apps/interactive/scivisStudio/Application.cpp +++ b/tsd/apps/interactive/scivisStudio/Application.cpp @@ -10,6 +10,7 @@ #include "modals/ProjectLocationDialog.h" #include "windows/CameraRigEditor.h" #include "windows/DatasetEditor.h" +#include "windows/LightRigEditor.h" #include "windows/ProjectWindow.h" #include "windows/ShotEditor.h" @@ -106,6 +107,7 @@ tsd::ui::imgui::WindowArray Application::setupWindows() 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); @@ -116,6 +118,7 @@ tsd::ui::imgui::WindowArray Application::setupWindows() 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); @@ -384,7 +387,9 @@ void Application::loadRecentProjects() const auto path = normalizedAbsolutePath(line); const auto duplicate = std::any_of(m_recentProjects.begin(), m_recentProjects.end(), - [&](const auto &entry) { return pathsReferToSameProject(entry, path); }); + [&](const auto &entry) { + return pathsReferToSameProject(entry, path); + }); if (!duplicate) m_recentProjects.push_back(path); diff --git a/tsd/apps/interactive/scivisStudio/Application.h b/tsd/apps/interactive/scivisStudio/Application.h index fe0139f38..5ee1beda8 100644 --- a/tsd/apps/interactive/scivisStudio/Application.h +++ b/tsd/apps/interactive/scivisStudio/Application.h @@ -26,6 +26,7 @@ struct AddDatasetDialog; struct CameraRigEditor; struct ConfirmDiscardDialog; struct DatasetEditor; +struct LightRigEditor; struct ProjectLocationDialog; struct ProjectWindow; struct ShotEditor; diff --git a/tsd/apps/interactive/scivisStudio/CMakeLists.txt b/tsd/apps/interactive/scivisStudio/CMakeLists.txt index a3c4036aa..080934ebc 100644 --- a/tsd/apps/interactive/scivisStudio/CMakeLists.txt +++ b/tsd/apps/interactive/scivisStudio/CMakeLists.txt @@ -29,6 +29,9 @@ PUBLIC 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) @@ -46,6 +49,7 @@ add_executable(scivisStudio scivisStudio.cpp windows/CameraRigEditor.cpp windows/DatasetEditor.cpp + windows/LightRigEditor.cpp windows/ProjectWindow.cpp windows/ShotEditor.cpp ) diff --git a/tsd/apps/interactive/scivisStudio/Dataset.h b/tsd/apps/interactive/scivisStudio/Dataset.h index 4b96fe539..ca15c1cd0 100644 --- a/tsd/apps/interactive/scivisStudio/Dataset.h +++ b/tsd/apps/interactive/scivisStudio/Dataset.h @@ -15,6 +15,7 @@ namespace tsd::scivis_studio { using DatasetID = std::string; using ShotID = std::string; using ColorMapID = std::string; +using LightRigID = std::string; struct SceneNodeRef { diff --git a/tsd/apps/interactive/scivisStudio/Project.cpp b/tsd/apps/interactive/scivisStudio/Project.cpp index 465513dd8..ea0998e38 100644 --- a/tsd/apps/interactive/scivisStudio/Project.cpp +++ b/tsd/apps/interactive/scivisStudio/Project.cpp @@ -46,6 +46,11 @@ 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(), @@ -78,6 +83,22 @@ const Shot *findShot(const Project &project, const ShotID &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)) diff --git a/tsd/apps/interactive/scivisStudio/Project.h b/tsd/apps/interactive/scivisStudio/Project.h index 905a4147f..0d7db71c5 100644 --- a/tsd/apps/interactive/scivisStudio/Project.h +++ b/tsd/apps/interactive/scivisStudio/Project.h @@ -18,12 +18,20 @@ struct ColorMapRecord 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}; @@ -37,11 +45,14 @@ 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); diff --git a/tsd/apps/interactive/scivisStudio/ProjectContext.cpp b/tsd/apps/interactive/scivisStudio/ProjectContext.cpp index 3d904607d..34f109442 100644 --- a/tsd/apps/interactive/scivisStudio/ProjectContext.cpp +++ b/tsd/apps/interactive/scivisStudio/ProjectContext.cpp @@ -105,6 +105,11 @@ 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 { @@ -146,23 +151,22 @@ tsd::scene::LayerNodeRef ProjectContext::resolveDatasetRoot(Dataset &dataset) return resolve(dataset.rootNode); } -tsd::scene::LayerNodeRef ProjectContext::resolveShotLightGroup(Shot &shot) +tsd::scene::LayerNodeRef ProjectContext::resolveLightRigRoot(LightRig &rig) { if (!m_ctx) return {}; auto *layer = m_ctx->tsd.scene.layer("studio"); if (layer) { - auto shotsRoot = findDirectChild(layer->root(), "shots"); - auto shotRoot = findDirectChild(shotsRoot, shot.id); - auto lightsRoot = findDirectChild(shotRoot, "lights"); - if (lightsRoot) { - shot.lightGroup = refFor("studio", lightsRoot); - return lightsRoot; + auto lightRigsRoot = findDirectChild(layer->root(), "lightRigs"); + auto rigRoot = findDirectChild(lightRigsRoot, rig.id); + if (rigRoot) { + rig.rootNode = refFor("studio", rigRoot); + return rigRoot; } } - return resolve(shot.lightGroup); + return resolve(rig.rootNode); } tsd::scene::Object *ProjectContext::resolveShotCamera(Shot &shot) @@ -196,6 +200,131 @@ void ProjectContext::ensureRendererDefaults(Shot &shot) } } +LightRig *ProjectContext::createLightRig(const std::string &name) +{ + if (!m_ctx) + return nullptr; + + LightRig rig; + rig.id = 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(); @@ -205,6 +334,7 @@ void ProjectContext::createUnsavedProject() auto datasetsRoot = ensureDatasetsRoot(); auto shotsRoot = ensureShotsRoot(); + auto *defaultRig = ensureDefaultLightRig(); (void)datasetsRoot; Shot shot; @@ -221,16 +351,9 @@ void ProjectContext::createUnsavedProject() manipulatorStateFromManipulator(m_ctx->view.manipulator); tsd::rendering::updateCameraObject(*camera, m_ctx->view.manipulator); - auto shotRoot = ensureChild(shotsRoot, shot.id.c_str()); - auto lightsRoot = ensureChild(shotRoot, "lights"); - shot.lightGroup = refFor("studio", lightsRoot); - - auto light = m_ctx->tsd.scene.createObject( - tsd::scene::tokens::light::directional); - light->setName("mainLight"); - light->setParameter("direction", tsd::math::float2(0.f, 240.f)); - light->setParameter("irradiance", 1.f); - m_ctx->tsd.scene.insertChildObjectNode(lightsRoot, light, "mainLight"); + 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; @@ -265,16 +388,9 @@ bool ProjectContext::addShot(const std::string &name) manipulatorStateFromManipulator(m_ctx->view.manipulator); tsd::rendering::updateCameraObject(*camera, m_ctx->view.manipulator); - auto shotRoot = ensureChild(ensureShotsRoot(), shot.id.c_str()); - auto lightsRoot = ensureChild(shotRoot, "lights"); - shot.lightGroup = refFor("studio", lightsRoot); - - auto light = m_ctx->tsd.scene.createObject( - tsd::scene::tokens::light::directional); - light->setName("mainLight"); - light->setParameter("direction", tsd::math::float2(0.f, 240.f)); - light->setParameter("irradiance", 1.f); - m_ctx->tsd.scene.insertChildObjectNode(lightsRoot, light, "mainLight"); + 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)); @@ -383,8 +499,8 @@ void ProjectContext::applyActiveShot() } }; - for (auto &s : m_project.shots) { - setNodeEnabled(resolveShotLightGroup(s), s.id == shot->id); + for (auto &rig : m_project.lightRigs) { + setNodeEnabled(resolveLightRigRoot(rig), rig.id == shot->lightRigId); } for (auto &dataset : m_project.datasets) { @@ -496,11 +612,11 @@ bool ProjectContext::saveProject(const std::filesystem::path &directory, 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}); + 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); @@ -563,6 +679,12 @@ bool ProjectContext::openProject(const std::filesystem::path &directory, 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(); @@ -608,12 +730,47 @@ 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) { - resolveShotLightGroup(shot); 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 = 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) { diff --git a/tsd/apps/interactive/scivisStudio/ProjectContext.h b/tsd/apps/interactive/scivisStudio/ProjectContext.h index 943410de0..82a769915 100644 --- a/tsd/apps/interactive/scivisStudio/ProjectContext.h +++ b/tsd/apps/interactive/scivisStudio/ProjectContext.h @@ -48,8 +48,14 @@ struct ProjectContext SceneNodeRef refFor( const std::string &layerName, tsd::scene::LayerNodeRef ref) const; tsd::scene::LayerNodeRef resolveDatasetRoot(Dataset &dataset); - tsd::scene::LayerNodeRef resolveShotLightGroup(Shot &shot); + tsd::scene::LayerNodeRef resolveLightRigRoot(LightRig &rig); tsd::scene::Object *resolveShotCamera(Shot &shot); + 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( @@ -57,8 +63,11 @@ struct ProjectContext 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(); diff --git a/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp b/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp index 3c788bb02..8077bcb7a 100644 --- a/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp +++ b/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp @@ -91,6 +91,7 @@ void projectToNode(const Project &project, tsd::core::DataNode &node) 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"]; @@ -111,6 +112,13 @@ void projectToNode(const Project &project, tsd::core::DataNode &node) } } + 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(); @@ -161,6 +169,7 @@ bool nodeToProject(tsd::core::DataNode &node, Project &project) 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); @@ -194,6 +203,13 @@ bool nodeToProject(tsd::core::DataNode &node, Project &project) }); } + 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(""), @@ -256,7 +272,7 @@ ProjectValidationResult validateProjectRoot( return result; } - if (metadata.schemaVersion != SCHEMA_VERSION) { + if (metadata.schemaVersion < 1 || metadata.schemaVersion > SCHEMA_VERSION) { result.error = "unsupported SciVis Studio schemaVersion"; return result; } @@ -268,7 +284,7 @@ ProjectValidationResult validateProjectRoot( } const auto version = root["schemaVersion"].getValueOr(0); - if (version != SCHEMA_VERSION) { + if (version < 1 || version > SCHEMA_VERSION) { result.error = "unsupported legacy SciVis Studio schemaVersion"; return result; } diff --git a/tsd/apps/interactive/scivisStudio/ProjectSerialization.h b/tsd/apps/interactive/scivisStudio/ProjectSerialization.h index 4a4032bb3..0171338f2 100644 --- a/tsd/apps/interactive/scivisStudio/ProjectSerialization.h +++ b/tsd/apps/interactive/scivisStudio/ProjectSerialization.h @@ -15,7 +15,7 @@ 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 = 1; +constexpr int SCHEMA_VERSION = 2; constexpr const char *PROJECT_MANIFEST_FILENAME = "project.tsd"; struct ProjectValidationResult diff --git a/tsd/apps/interactive/scivisStudio/RenderShot.cpp b/tsd/apps/interactive/scivisStudio/RenderShot.cpp index 79b5494e6..0849aa22b 100644 --- a/tsd/apps/interactive/scivisStudio/RenderShot.cpp +++ b/tsd/apps/interactive/scivisStudio/RenderShot.cpp @@ -87,14 +87,15 @@ bool renderActiveShotToFrames( 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); + 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 " diff --git a/tsd/apps/interactive/scivisStudio/Shot.h b/tsd/apps/interactive/scivisStudio/Shot.h index 5562385b7..68f5ee5c5 100644 --- a/tsd/apps/interactive/scivisStudio/Shot.h +++ b/tsd/apps/interactive/scivisStudio/Shot.h @@ -39,7 +39,7 @@ struct Shot bool playing{false}; bool loop{true}; std::vector datasetBindings; - SceneNodeRef lightGroup; + LightRigID lightRigId; SceneObjectRef camera; ShotCameraRig cameraRig; ShotRenderSettings renderSettings; diff --git a/tsd/apps/interactive/scivisStudio/default_ui_layout.txt b/tsd/apps/interactive/scivisStudio/default_ui_layout.txt index cab8308df..784eb039e 100644 --- a/tsd/apps/interactive/scivisStudio/default_ui_layout.txt +++ b/tsd/apps/interactive/scivisStudio/default_ui_layout.txt @@ -1,46 +1,52 @@ [Window][MainDockSpace] -Pos=0,42 -Size=3840,1980 +Pos=0,56 +Size=3840,2206 Collapsed=0 [Window][Project] -Pos=0,42 -Size=872,577 +Pos=0,56 +Size=872,643 Collapsed=0 DockId=0x00000002,0 [Window][Dataset Editor] -Pos=0,1453 -Size=872,569 +Pos=0,1628 +Size=872,634 Collapsed=0 DockId=0x0000000A,0 [Window][Shot Editor] -Pos=0,621 -Size=872,830 +Pos=0,701 +Size=872,925 Collapsed=0 DockId=0x0000000B,0 +[Window][Light Rig] +Pos=874,1692 +Size=1485,570 +Collapsed=0 +DockId=0x00000003,1 + [Window][Camera Rig] -Pos=874,1452 +Pos=874,1692 Size=1485,570 Collapsed=0 DockId=0x00000003,0 [Window][Viewport] -Pos=874,42 -Size=2966,1408 +Pos=874,56 +Size=2966,1634 Collapsed=0 DockId=0x00000005,0 [Window][Object Editor] -Pos=0,1453 -Size=872,569 +Pos=0,1628 +Size=872,634 Collapsed=0 DockId=0x0000000A,1 [Window][Log] -Pos=2361,1452 +Pos=2361,1692 Size=1479,570 Collapsed=0 DockId=0x00000004,0 @@ -61,8 +67,8 @@ Size=400,400 Collapsed=0 [Window][##blocking_task_modal] -Pos=1672,927 -Size=496,168 +Pos=1592,1023 +Size=656,216 Collapsed=0 [Window][Add Dataset] @@ -91,7 +97,7 @@ Column 0 Weight=1.0000 Column 1 Weight=1.0000 [Docking][Data] -DockSpace ID=0x80F5B4C5 Window=0x079D3A04 Pos=0,42 Size=3840,1980 Split=X Selected=0xC450F867 +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 diff --git a/tsd/apps/interactive/scivisStudio/windows/LightRigEditor.cpp b/tsd/apps/interactive/scivisStudio/windows/LightRigEditor.cpp new file mode 100644 index 000000000..897638504 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/windows/LightRigEditor.cpp @@ -0,0 +1,294 @@ +// 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 = 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 = 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/ShotEditor.cpp b/tsd/apps/interactive/scivisStudio/windows/ShotEditor.cpp index aabc8e8cd..7b3929c57 100644 --- a/tsd/apps/interactive/scivisStudio/windows/ShotEditor.cpp +++ b/tsd/apps/interactive/scivisStudio/windows/ShotEditor.cpp @@ -157,6 +157,50 @@ void ShotEditor::buildUI_rendererSelector(Shot &shot) ImGui::EndDisabled(); } +void ShotEditor::buildUI_lightRigSelector(Shot &shot) +{ + auto &project = m_projectContext->project(); + std::string preview = "None"; + if (!shot.lightRigId.empty()) { + if (auto *rig = 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() && !findLightRig(project, shot.lightRigId)) { + const auto missing = ""; + ImGui::TextDisabled("%s", missing.c_str()); + } + ImGui::EndCombo(); + } +} + void ShotEditor::buildUI() { if (!m_projectContext) @@ -234,6 +278,7 @@ void ShotEditor::buildUI() 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) diff --git a/tsd/apps/interactive/scivisStudio/windows/ShotEditor.h b/tsd/apps/interactive/scivisStudio/windows/ShotEditor.h index dadf2b600..3d46df4eb 100644 --- a/tsd/apps/interactive/scivisStudio/windows/ShotEditor.h +++ b/tsd/apps/interactive/scivisStudio/windows/ShotEditor.h @@ -24,6 +24,7 @@ struct ShotEditor : public tsd::ui::imgui::Window 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; diff --git a/tsd/tests/test_SciVisStudio.cpp b/tsd/tests/test_SciVisStudio.cpp index d8d2a5009..e34d3a161 100644 --- a/tsd/tests/test_SciVisStudio.cpp +++ b/tsd/tests/test_SciVisStudio.cpp @@ -10,7 +10,9 @@ #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 @@ -47,7 +49,7 @@ tsd::scene::LayerNodeRef findDirectChild( SCENARIO("SciVis Studio project model serialization", "[SciVisStudio]") { - GIVEN("A project with datasets, shots, and camera keyframes") + GIVEN("A project with datasets, shots, light rigs, and camera keyframes") { Project project; project.name = "RoundTrip"; @@ -64,7 +66,7 @@ SCENARIO("SciVis Studio project model serialization", "[SciVisStudio]") shot.id = "shot_0001"; shot.name = "Shot 1"; shot.datasetBindings.push_back({"dataset_0001", true}); - shot.lightGroup = {"studio", 5}; + shot.lightRigId = "lightRig_0001"; shot.camera = {ANARI_CAMERA, 2}; shot.renderSettings.rendererLibrary = "dummy_test_device"; shot.renderSettings.rendererObjectIndex = 7; @@ -78,14 +80,16 @@ SCENARIO("SciVis Studio project model serialization", "[SciVisStudio]") 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["shots"].child(0)->child("lightGroup") == 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)); @@ -96,6 +100,9 @@ SCENARIO("SciVis Studio project model serialization", "[SciVisStudio]") REQUIRE(loaded.datasets.front().id == "dataset_0001"); 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); @@ -173,11 +180,11 @@ SCENARIO("SciVis Studio project root validation", "[SciVisStudio]") 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}); + 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") @@ -191,7 +198,7 @@ SCENARIO("SciVis Studio project root validation", "[SciVisStudio]") { tsd::core::DataTree tree; tree.root()["projectKind"] = PROJECT_KIND; - tree.root()["schemaVersion"] = SCHEMA_VERSION; + tree.root()["schemaVersion"] = 1; REQUIRE(tree.save((root / PROJECT_MANIFEST_FILENAME).string().c_str())); THEN("Validation succeeds") @@ -204,11 +211,11 @@ SCENARIO("SciVis Studio project root validation", "[SciVisStudio]") 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}); + 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") @@ -232,6 +239,23 @@ SCENARIO("SciVis Studio project root validation", "[SciVisStudio]") } } + 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); } @@ -244,9 +268,30 @@ SCENARIO("SciVis Studio default project creation", "[SciVisStudio]") 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(activeShot(projectContext.project())->lightRigId == defaultRigId); } SCENARIO("SciVis Studio shot dataset bindings update scene visibility", @@ -369,8 +414,9 @@ SCENARIO("SciVis Studio saved projects rebuild runtime refs from stable IDs", auto &projectNode = manifest.root()["scivisStudio"]; REQUIRE(projectNode["datasets"].child(0)->child("rootNode") == nullptr); - REQUIRE(projectNode["shots"].child(0)->child("lightGroup") == 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); } { @@ -389,6 +435,123 @@ SCENARIO("SciVis Studio saved projects rebuild runtime refs from stable IDs", 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 = 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 = *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 = 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]") { From f1c75aa5a0cebaffc094643e30cddd82ac45cf81 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Wed, 27 May 2026 11:33:25 -0600 Subject: [PATCH 37/49] update default UI layout --- .../scivisStudio/default_ui_layout.txt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tsd/apps/interactive/scivisStudio/default_ui_layout.txt b/tsd/apps/interactive/scivisStudio/default_ui_layout.txt index 784eb039e..a8e450154 100644 --- a/tsd/apps/interactive/scivisStudio/default_ui_layout.txt +++ b/tsd/apps/interactive/scivisStudio/default_ui_layout.txt @@ -22,20 +22,20 @@ Collapsed=0 DockId=0x0000000B,0 [Window][Light Rig] -Pos=874,1692 -Size=1485,570 +Pos=874,1595 +Size=1485,667 Collapsed=0 DockId=0x00000003,1 [Window][Camera Rig] -Pos=874,1692 -Size=1485,570 +Pos=874,1595 +Size=1485,667 Collapsed=0 DockId=0x00000003,0 [Window][Viewport] Pos=874,56 -Size=2966,1634 +Size=2966,1537 Collapsed=0 DockId=0x00000005,0 @@ -46,8 +46,8 @@ Collapsed=0 DockId=0x0000000A,1 [Window][Log] -Pos=2361,1692 -Size=1479,570 +Pos=2361,1595 +Size=1479,667 Collapsed=0 DockId=0x00000004,0 @@ -104,8 +104,8 @@ DockSpace ID=0x80F5B4C5 Window=0x079D3A04 Pos=0,56 Size=3840,2206 Split=X 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,1408 CentralNode=1 Selected=0xC450F867 - DockNode ID=0x00000008 Parent=0x00000009 SizeRef=3840,570 Split=X Selected=0x4192BA76 + 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 From de493a88702dbf93861c1f96d6e602c9aecbce3e Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Wed, 27 May 2026 11:46:19 -0600 Subject: [PATCH 38/49] Add confirmation dialog for updating UI layout default --- .../interactive/scivisStudio/Application.cpp | 15 ++++++- .../interactive/scivisStudio/Application.h | 2 + .../interactive/scivisStudio/CMakeLists.txt | 1 + .../modals/ConfirmDefaultLayoutDialog.cpp | 40 +++++++++++++++++++ .../modals/ConfirmDefaultLayoutDialog.h | 25 ++++++++++++ 5 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 tsd/apps/interactive/scivisStudio/modals/ConfirmDefaultLayoutDialog.cpp create mode 100644 tsd/apps/interactive/scivisStudio/modals/ConfirmDefaultLayoutDialog.h diff --git a/tsd/apps/interactive/scivisStudio/Application.cpp b/tsd/apps/interactive/scivisStudio/Application.cpp index ee06cf41f..8a4f9a193 100644 --- a/tsd/apps/interactive/scivisStudio/Application.cpp +++ b/tsd/apps/interactive/scivisStudio/Application.cpp @@ -6,6 +6,7 @@ #include "DefaultLayout.h" #include "RenderShot.h" #include "modals/AddDatasetDialog.h" +#include "modals/ConfirmDefaultLayoutDialog.h" #include "modals/ConfirmDiscardDialog.h" #include "modals/ProjectLocationDialog.h" #include "windows/CameraRigEditor.h" @@ -133,6 +134,8 @@ tsd::ui::imgui::WindowArray Application::setupWindows() m_transferFunctionEditor->hide(); m_projectLocationDialog = std::make_unique(this); + m_confirmDefaultLayoutDialog = + std::make_unique(this); m_confirmDiscardDialog = std::make_unique(this); m_addDatasetDialog = std::make_unique(this, &m_projectContext); @@ -615,6 +618,11 @@ void Application::uiFrameStart() modalActive = true; } + if (m_confirmDefaultLayoutDialog && m_confirmDefaultLayoutDialog->visible()) { + m_confirmDefaultLayoutDialog->renderUI(); + modalActive = true; + } + if (m_addDatasetDialog && m_addDatasetDialog->visible()) { m_addDatasetDialog->renderUI(); modalActive = true; @@ -675,8 +683,11 @@ void Application::uiMainMenuBar() ImGui::PopID(); } ImGui::Separator(); - if (ImGui::MenuItem("Save Default Layout File")) - saveDefaultLayoutFile(); + if (ImGui::MenuItem("Save Default Layout File")) { + m_confirmDefaultLayoutDialog->configure( + [this]() { saveDefaultLayoutFile(); }); + m_confirmDefaultLayoutDialog->show(); + } if (ImGui::MenuItem("Reset Layout")) ImGui::LoadIniSettingsFromMemory(getDefaultLayout()); ImGui::EndMenu(); diff --git a/tsd/apps/interactive/scivisStudio/Application.h b/tsd/apps/interactive/scivisStudio/Application.h index 5ee1beda8..9eb0a43bf 100644 --- a/tsd/apps/interactive/scivisStudio/Application.h +++ b/tsd/apps/interactive/scivisStudio/Application.h @@ -24,6 +24,7 @@ namespace tsd::scivis_studio { struct AddDatasetDialog; struct CameraRigEditor; +struct ConfirmDefaultLayoutDialog; struct ConfirmDiscardDialog; struct DatasetEditor; struct LightRigEditor; @@ -97,6 +98,7 @@ class Application : public tsd::ui::imgui::Application tsd::ui::imgui::TransferFunctionEditor *m_transferFunctionEditor{nullptr}; std::unique_ptr m_projectLocationDialog; + std::unique_ptr m_confirmDefaultLayoutDialog; std::unique_ptr m_confirmDiscardDialog; std::unique_ptr m_addDatasetDialog; }; diff --git a/tsd/apps/interactive/scivisStudio/CMakeLists.txt b/tsd/apps/interactive/scivisStudio/CMakeLists.txt index 080934ebc..0f1d665bb 100644 --- a/tsd/apps/interactive/scivisStudio/CMakeLists.txt +++ b/tsd/apps/interactive/scivisStudio/CMakeLists.txt @@ -44,6 +44,7 @@ configure_file( add_executable(scivisStudio Application.cpp modals/AddDatasetDialog.cpp + modals/ConfirmDefaultLayoutDialog.cpp modals/ConfirmDiscardDialog.cpp modals/ProjectLocationDialog.cpp scivisStudio.cpp diff --git a/tsd/apps/interactive/scivisStudio/modals/ConfirmDefaultLayoutDialog.cpp b/tsd/apps/interactive/scivisStudio/modals/ConfirmDefaultLayoutDialog.cpp new file mode 100644 index 000000000..375ec7381 --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/modals/ConfirmDefaultLayoutDialog.cpp @@ -0,0 +1,40 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#include "ConfirmDefaultLayoutDialog.h" + +#include "imgui.h" + +namespace tsd::scivis_studio { + +ConfirmDefaultLayoutDialog::ConfirmDefaultLayoutDialog( + tsd::ui::imgui::Application *app) + : Modal(app, "Update Default Layout") +{} + +ConfirmDefaultLayoutDialog::~ConfirmDefaultLayoutDialog() = default; + +void ConfirmDefaultLayoutDialog::configure(std::function onConfirm) +{ + m_onConfirm = std::move(onConfirm); +} + +void ConfirmDefaultLayoutDialog::buildUI() +{ + ImGui::Dummy(ImVec2(700.f, 0.f)); + ImGui::TextUnformatted("Are you sure?"); + ImGui::Spacing(); + + if (ImGui::Button("No")) + hide(); + + ImGui::SameLine(); + + if (ImGui::Button("Yes")) { + hide(); + if (m_onConfirm) + m_onConfirm(); + } +} + +} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/modals/ConfirmDefaultLayoutDialog.h b/tsd/apps/interactive/scivisStudio/modals/ConfirmDefaultLayoutDialog.h new file mode 100644 index 000000000..c8b5fffaf --- /dev/null +++ b/tsd/apps/interactive/scivisStudio/modals/ConfirmDefaultLayoutDialog.h @@ -0,0 +1,25 @@ +// Copyright 2026 NVIDIA Corporation +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "tsd/ui/imgui/modals/Modal.h" + +#include + +namespace tsd::scivis_studio { + +struct ConfirmDefaultLayoutDialog : public tsd::ui::imgui::Modal +{ + explicit ConfirmDefaultLayoutDialog(tsd::ui::imgui::Application *app); + ~ConfirmDefaultLayoutDialog() override; + + void configure(std::function onConfirm); + + private: + void buildUI() override; + + std::function m_onConfirm; +}; + +} // namespace tsd::scivis_studio From 1bfa8accfac1235e18605d79afcd9a9e987d38f3 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Wed, 27 May 2026 11:52:52 -0600 Subject: [PATCH 39/49] consolidate confirmation dialog code paths --- .../interactive/scivisStudio/Application.cpp | 84 ++++++++++++++----- .../interactive/scivisStudio/Application.h | 18 +++- .../interactive/scivisStudio/CMakeLists.txt | 2 - .../modals/ConfirmDefaultLayoutDialog.cpp | 40 --------- .../modals/ConfirmDefaultLayoutDialog.h | 25 ------ .../modals/ConfirmDiscardDialog.cpp | 43 ---------- .../modals/ConfirmDiscardDialog.h | 27 ------ 7 files changed, 75 insertions(+), 164 deletions(-) delete mode 100644 tsd/apps/interactive/scivisStudio/modals/ConfirmDefaultLayoutDialog.cpp delete mode 100644 tsd/apps/interactive/scivisStudio/modals/ConfirmDefaultLayoutDialog.h delete mode 100644 tsd/apps/interactive/scivisStudio/modals/ConfirmDiscardDialog.cpp delete mode 100644 tsd/apps/interactive/scivisStudio/modals/ConfirmDiscardDialog.h diff --git a/tsd/apps/interactive/scivisStudio/Application.cpp b/tsd/apps/interactive/scivisStudio/Application.cpp index 8a4f9a193..8010bfe56 100644 --- a/tsd/apps/interactive/scivisStudio/Application.cpp +++ b/tsd/apps/interactive/scivisStudio/Application.cpp @@ -6,8 +6,6 @@ #include "DefaultLayout.h" #include "RenderShot.h" #include "modals/AddDatasetDialog.h" -#include "modals/ConfirmDefaultLayoutDialog.h" -#include "modals/ConfirmDiscardDialog.h" #include "modals/ProjectLocationDialog.h" #include "windows/CameraRigEditor.h" #include "windows/DatasetEditor.h" @@ -76,6 +74,47 @@ bool pathsReferToSameProject( 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) @@ -134,9 +173,6 @@ tsd::ui::imgui::WindowArray Application::setupWindows() m_transferFunctionEditor->hide(); m_projectLocationDialog = std::make_unique(this); - m_confirmDefaultLayoutDialog = - std::make_unique(this); - m_confirmDiscardDialog = std::make_unique(this); m_addDatasetDialog = std::make_unique(this, &m_projectContext); @@ -329,12 +365,17 @@ void Application::requestDirtyAction(PendingDirtyAction action) } m_pendingDirtyAction = action; - m_confirmDiscardDialog->configure([this]() { continueDirtyAction(); }, - [this]() { - m_pendingDirtyAction = PendingDirtyAction::None; - m_pendingProjectDirectory.clear(); - }); - m_confirmDiscardDialog->show(); + 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( @@ -613,15 +654,7 @@ void Application::uiFrameStart() modalActive = true; } - if (m_confirmDiscardDialog && m_confirmDiscardDialog->visible()) { - m_confirmDiscardDialog->renderUI(); - modalActive = true; - } - - if (m_confirmDefaultLayoutDialog && m_confirmDefaultLayoutDialog->visible()) { - m_confirmDefaultLayoutDialog->renderUI(); - modalActive = true; - } + modalActive = renderConfirmationModal(m_confirmationModal) || modalActive; if (m_addDatasetDialog && m_addDatasetDialog->visible()) { m_addDatasetDialog->renderUI(); @@ -684,9 +717,14 @@ void Application::uiMainMenuBar() } ImGui::Separator(); if (ImGui::MenuItem("Save Default Layout File")) { - m_confirmDefaultLayoutDialog->configure( - [this]() { saveDefaultLayoutFile(); }); - m_confirmDefaultLayoutDialog->show(); + 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()); diff --git a/tsd/apps/interactive/scivisStudio/Application.h b/tsd/apps/interactive/scivisStudio/Application.h index 9eb0a43bf..59a83bfd7 100644 --- a/tsd/apps/interactive/scivisStudio/Application.h +++ b/tsd/apps/interactive/scivisStudio/Application.h @@ -8,6 +8,7 @@ #include "tsd/ui/imgui/Application.h" #include +#include #include #include #include @@ -24,14 +25,24 @@ namespace tsd::scivis_studio { struct AddDatasetDialog; struct CameraRigEditor; -struct ConfirmDefaultLayoutDialog; -struct ConfirmDiscardDialog; 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: @@ -98,9 +109,8 @@ class Application : public tsd::ui::imgui::Application tsd::ui::imgui::TransferFunctionEditor *m_transferFunctionEditor{nullptr}; std::unique_ptr m_projectLocationDialog; - std::unique_ptr m_confirmDefaultLayoutDialog; - std::unique_ptr m_confirmDiscardDialog; std::unique_ptr m_addDatasetDialog; + ConfirmationModalState m_confirmationModal; }; } // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/CMakeLists.txt b/tsd/apps/interactive/scivisStudio/CMakeLists.txt index 0f1d665bb..b998c6541 100644 --- a/tsd/apps/interactive/scivisStudio/CMakeLists.txt +++ b/tsd/apps/interactive/scivisStudio/CMakeLists.txt @@ -44,8 +44,6 @@ configure_file( add_executable(scivisStudio Application.cpp modals/AddDatasetDialog.cpp - modals/ConfirmDefaultLayoutDialog.cpp - modals/ConfirmDiscardDialog.cpp modals/ProjectLocationDialog.cpp scivisStudio.cpp windows/CameraRigEditor.cpp diff --git a/tsd/apps/interactive/scivisStudio/modals/ConfirmDefaultLayoutDialog.cpp b/tsd/apps/interactive/scivisStudio/modals/ConfirmDefaultLayoutDialog.cpp deleted file mode 100644 index 375ec7381..000000000 --- a/tsd/apps/interactive/scivisStudio/modals/ConfirmDefaultLayoutDialog.cpp +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2026 NVIDIA Corporation -// SPDX-License-Identifier: Apache-2.0 - -#include "ConfirmDefaultLayoutDialog.h" - -#include "imgui.h" - -namespace tsd::scivis_studio { - -ConfirmDefaultLayoutDialog::ConfirmDefaultLayoutDialog( - tsd::ui::imgui::Application *app) - : Modal(app, "Update Default Layout") -{} - -ConfirmDefaultLayoutDialog::~ConfirmDefaultLayoutDialog() = default; - -void ConfirmDefaultLayoutDialog::configure(std::function onConfirm) -{ - m_onConfirm = std::move(onConfirm); -} - -void ConfirmDefaultLayoutDialog::buildUI() -{ - ImGui::Dummy(ImVec2(700.f, 0.f)); - ImGui::TextUnformatted("Are you sure?"); - ImGui::Spacing(); - - if (ImGui::Button("No")) - hide(); - - ImGui::SameLine(); - - if (ImGui::Button("Yes")) { - hide(); - if (m_onConfirm) - m_onConfirm(); - } -} - -} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/modals/ConfirmDefaultLayoutDialog.h b/tsd/apps/interactive/scivisStudio/modals/ConfirmDefaultLayoutDialog.h deleted file mode 100644 index c8b5fffaf..000000000 --- a/tsd/apps/interactive/scivisStudio/modals/ConfirmDefaultLayoutDialog.h +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright 2026 NVIDIA Corporation -// SPDX-License-Identifier: Apache-2.0 - -#pragma once - -#include "tsd/ui/imgui/modals/Modal.h" - -#include - -namespace tsd::scivis_studio { - -struct ConfirmDefaultLayoutDialog : public tsd::ui::imgui::Modal -{ - explicit ConfirmDefaultLayoutDialog(tsd::ui::imgui::Application *app); - ~ConfirmDefaultLayoutDialog() override; - - void configure(std::function onConfirm); - - private: - void buildUI() override; - - std::function m_onConfirm; -}; - -} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/modals/ConfirmDiscardDialog.cpp b/tsd/apps/interactive/scivisStudio/modals/ConfirmDiscardDialog.cpp deleted file mode 100644 index fd21b35db..000000000 --- a/tsd/apps/interactive/scivisStudio/modals/ConfirmDiscardDialog.cpp +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright 2026 NVIDIA Corporation -// SPDX-License-Identifier: Apache-2.0 - -#include "ConfirmDiscardDialog.h" - -#include "imgui.h" - -namespace tsd::scivis_studio { - -ConfirmDiscardDialog::ConfirmDiscardDialog(tsd::ui::imgui::Application *app) - : Modal(app, "Discard Unsaved Changes") -{} - -ConfirmDiscardDialog::~ConfirmDiscardDialog() = default; - -void ConfirmDiscardDialog::configure( - std::function onDiscard, std::function onCancel) -{ - m_onDiscard = std::move(onDiscard); - m_onCancel = std::move(onCancel); -} - -void ConfirmDiscardDialog::buildUI() -{ - ImGui::TextUnformatted("The current project has unsaved changes."); - ImGui::Spacing(); - - if (ImGui::Button("Cancel")) { - hide(); - if (m_onCancel) - m_onCancel(); - } - - ImGui::SameLine(); - - if (ImGui::Button("Discard and Continue")) { - hide(); - if (m_onDiscard) - m_onDiscard(); - } -} - -} // namespace tsd::scivis_studio diff --git a/tsd/apps/interactive/scivisStudio/modals/ConfirmDiscardDialog.h b/tsd/apps/interactive/scivisStudio/modals/ConfirmDiscardDialog.h deleted file mode 100644 index aece359ad..000000000 --- a/tsd/apps/interactive/scivisStudio/modals/ConfirmDiscardDialog.h +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright 2026 NVIDIA Corporation -// SPDX-License-Identifier: Apache-2.0 - -#pragma once - -#include "tsd/ui/imgui/modals/Modal.h" - -#include - -namespace tsd::scivis_studio { - -struct ConfirmDiscardDialog : public tsd::ui::imgui::Modal -{ - explicit ConfirmDiscardDialog(tsd::ui::imgui::Application *app); - ~ConfirmDiscardDialog() override; - - void configure( - std::function onDiscard, std::function onCancel); - - private: - void buildUI() override; - - std::function m_onDiscard; - std::function m_onCancel; -}; - -} // namespace tsd::scivis_studio From d5c9b561bc465d15bf3f96a8ca4ca19ceb194619 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Wed, 27 May 2026 16:25:15 -0600 Subject: [PATCH 40/49] Project->New no longer prompts for a project directory --- .../interactive/scivisStudio/Application.cpp | 25 ++----------------- .../interactive/scivisStudio/Application.h | 2 -- .../modals/ProjectLocationDialog.cpp | 5 +--- .../modals/ProjectLocationDialog.h | 1 - 4 files changed, 3 insertions(+), 30 deletions(-) diff --git a/tsd/apps/interactive/scivisStudio/Application.cpp b/tsd/apps/interactive/scivisStudio/Application.cpp index 8010bfe56..6849c4d2f 100644 --- a/tsd/apps/interactive/scivisStudio/Application.cpp +++ b/tsd/apps/interactive/scivisStudio/Application.cpp @@ -347,15 +347,6 @@ void Application::newProject() restoreViewportFromActiveShot(); } -void Application::closeProject() -{ - if (m_viewport) - m_viewport->releaseSceneReferences(); - - m_projectContext.createUnsavedProject(); - restoreViewportFromActiveShot(); -} - void Application::requestDirtyAction(PendingDirtyAction action) { if (!m_projectContext.project().dirty) { @@ -391,7 +382,7 @@ void Application::continueDirtyAction() m_pendingDirtyAction = PendingDirtyAction::None; if (action == PendingDirtyAction::NewProject) - showProjectLocationDialogForNew(); + newProject(); else if (action == PendingDirtyAction::OpenProject) showProjectLocationDialogForOpen(); else if (action == PendingDirtyAction::OpenRecentProject) { @@ -540,16 +531,6 @@ void Application::showAddDatasetDialog() m_addDatasetDialog->show(); } -void Application::showProjectLocationDialogForNew() -{ - m_projectLocationDialog->configure(ProjectLocationMode::NewProject, - [this](const std::filesystem::path &directory) { - newProject(); - saveProjectAs(directory); - }); - m_projectLocationDialog->show(); -} - void Application::showProjectLocationDialogForOpen() { m_projectLocationDialog->configure(ProjectLocationMode::OpenProject, @@ -680,7 +661,7 @@ void Application::uiFrameStart() void Application::uiMainMenuBar() { if (ImGui::BeginMenu("Project")) { - if (ImGui::MenuItem("New ...")) + if (ImGui::MenuItem("New")) requestDirtyAction(PendingDirtyAction::NewProject); if (ImGui::MenuItem("Open ...")) requestDirtyAction(PendingDirtyAction::OpenProject); @@ -691,8 +672,6 @@ void Application::uiMainMenuBar() saveProject(); if (ImGui::MenuItem("Save As...")) showProjectLocationDialogForSaveAs(); - if (ImGui::MenuItem("Close")) - requestDirtyAction(PendingDirtyAction::NewProject); ImGui::Separator(); if (ImGui::MenuItem("Quit")) std::exit(0); diff --git a/tsd/apps/interactive/scivisStudio/Application.h b/tsd/apps/interactive/scivisStudio/Application.h index 59a83bfd7..9f6409228 100644 --- a/tsd/apps/interactive/scivisStudio/Application.h +++ b/tsd/apps/interactive/scivisStudio/Application.h @@ -53,7 +53,6 @@ class Application : public tsd::ui::imgui::Application const ProjectContext &projectContext() const; void showAddDatasetDialog(); - void showProjectLocationDialogForNew(); void showProjectLocationDialogForOpen(); void showProjectLocationDialogForSaveAs(); void renderActiveShot(); @@ -78,7 +77,6 @@ class Application : public tsd::ui::imgui::Application bool saveProjectAs(const std::filesystem::path &directory); bool openProject(const std::filesystem::path &directory); void newProject(); - void closeProject(); void saveDefaultLayoutFile() const; void saveWindowSettings(tsd::core::DataNode &node); void loadWindowSettings(tsd::core::DataNode &node); diff --git a/tsd/apps/interactive/scivisStudio/modals/ProjectLocationDialog.cpp b/tsd/apps/interactive/scivisStudio/modals/ProjectLocationDialog.cpp index 8837e0e23..4a03988c5 100644 --- a/tsd/apps/interactive/scivisStudio/modals/ProjectLocationDialog.cpp +++ b/tsd/apps/interactive/scivisStudio/modals/ProjectLocationDialog.cpp @@ -71,10 +71,7 @@ void ProjectLocationDialog::buildUI() { const char *title = "Open Project"; const char *button = "Open"; - if (m_mode == ProjectLocationMode::NewProject) { - title = "New Project"; - button = "Create"; - } else if (m_mode == ProjectLocationMode::SaveProjectAs) { + if (m_mode == ProjectLocationMode::SaveProjectAs) { title = "Save Project As"; button = "Save"; } diff --git a/tsd/apps/interactive/scivisStudio/modals/ProjectLocationDialog.h b/tsd/apps/interactive/scivisStudio/modals/ProjectLocationDialog.h index e39f6decb..2d4555f74 100644 --- a/tsd/apps/interactive/scivisStudio/modals/ProjectLocationDialog.h +++ b/tsd/apps/interactive/scivisStudio/modals/ProjectLocationDialog.h @@ -14,7 +14,6 @@ namespace tsd::scivis_studio { enum class ProjectLocationMode { - NewProject, OpenProject, SaveProjectAs }; From c0a14686b4ef18cd6fe07381609ed33766e3028f Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Wed, 27 May 2026 16:31:28 -0600 Subject: [PATCH 41/49] ensure that the app starts in a "clean" state --- tsd/apps/interactive/scivisStudio/Application.cpp | 8 ++++++++ tsd/apps/interactive/scivisStudio/Application.h | 1 + 2 files changed, 9 insertions(+) diff --git a/tsd/apps/interactive/scivisStudio/Application.cpp b/tsd/apps/interactive/scivisStudio/Application.cpp index 6849c4d2f..9684068ef 100644 --- a/tsd/apps/interactive/scivisStudio/Application.cpp +++ b/tsd/apps/interactive/scivisStudio/Application.cpp @@ -179,10 +179,12 @@ tsd::ui::imgui::WindowArray Application::setupWindows() if (!m_initialProjectDirectory.empty()) { if (!openProject(m_initialProjectDirectory)) { m_projectContext.createUnsavedProject(); + m_keepBlankProjectCleanAfterViewportSync = true; m_viewport->setLibraryToDefault(); } } else { m_projectContext.createUnsavedProject(); + m_keepBlankProjectCleanAfterViewportSync = true; m_viewport->setLibraryToDefault(); } @@ -344,6 +346,7 @@ void Application::newProject() m_viewport->releaseSceneReferences(); m_projectContext.createUnsavedProject(); + m_keepBlankProjectCleanAfterViewportSync = true; restoreViewportFromActiveShot(); } @@ -656,6 +659,11 @@ void Application::uiFrameStart() appContext()->clearSelected(); syncActiveShotRenderSettingsFromViewport(); + if (m_keepBlankProjectCleanAfterViewportSync + && (!m_taskModal || !m_taskModal->visible())) { + m_projectContext.project().markClean(); + m_keepBlankProjectCleanAfterViewportSync = false; + } } void Application::uiMainMenuBar() diff --git a/tsd/apps/interactive/scivisStudio/Application.h b/tsd/apps/interactive/scivisStudio/Application.h index 9f6409228..644d9bae2 100644 --- a/tsd/apps/interactive/scivisStudio/Application.h +++ b/tsd/apps/interactive/scivisStudio/Application.h @@ -101,6 +101,7 @@ class Application : public tsd::ui::imgui::Application std::vector m_recentProjects; PendingDirtyAction m_pendingDirtyAction{PendingDirtyAction::None}; bool m_viewportRenderingDisabledForShotRender{false}; + bool m_keepBlankProjectCleanAfterViewportSync{false}; tsd::ui::imgui::Viewport *m_viewport{nullptr}; tsd::ui::imgui::LayerTree *m_layerTree{nullptr}; From 5eea49175f30b48028f28d2d13a27015fa983955 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Thu, 28 May 2026 10:57:30 -0600 Subject: [PATCH 42/49] add hook for injecting UI style overrides for app windows --- tsd/apps/interactive/mpiViewer/DistributedViewport.cpp | 6 ++++++ tsd/apps/interactive/mpiViewer/DistributedViewport.h | 1 + tsd/src/tsd/ui/imgui/Application.cpp | 2 +- tsd/src/tsd/ui/imgui/windows/BaseViewport.cpp | 6 ++++++ tsd/src/tsd/ui/imgui/windows/BaseViewport.h | 1 + tsd/src/tsd/ui/imgui/windows/MultiDeviceViewport.cpp | 6 ++++++ tsd/src/tsd/ui/imgui/windows/MultiDeviceViewport.h | 1 + tsd/src/tsd/ui/imgui/windows/Window.cpp | 8 ++++++++ tsd/src/tsd/ui/imgui/windows/Window.h | 1 + 9 files changed, 31 insertions(+), 1 deletion(-) 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/src/tsd/ui/imgui/Application.cpp b/tsd/src/tsd/ui/imgui/Application.cpp index 2ba1a15b2..600822b8c 100644 --- a/tsd/src/tsd/ui/imgui/Application.cpp +++ b/tsd/src/tsd/ui/imgui/Application.cpp @@ -196,7 +196,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); diff --git a/tsd/src/tsd/ui/imgui/windows/BaseViewport.cpp b/tsd/src/tsd/ui/imgui/windows/BaseViewport.cpp index c62df8646..8efae037a 100644 --- a/tsd/src/tsd/ui/imgui/windows/BaseViewport.cpp +++ b/tsd/src/tsd/ui/imgui/windows/BaseViewport.cpp @@ -657,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 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/MultiDeviceViewport.cpp b/tsd/src/tsd/ui/imgui/windows/MultiDeviceViewport.cpp index 183bdeee1..30ac1a70f 100644 --- a/tsd/src/tsd/ui/imgui/windows/MultiDeviceViewport.cpp +++ b/tsd/src/tsd/ui/imgui/windows/MultiDeviceViewport.cpp @@ -527,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/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}; From 308f6c6db1dfd1c1896837a6a4485b22c65cf31e Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 29 May 2026 11:29:16 -0600 Subject: [PATCH 43/49] use namespaces to organize major concepts --- .../interactive/scivisStudio/Application.cpp | 11 +++--- tsd/apps/interactive/scivisStudio/Dataset.cpp | 8 ++-- tsd/apps/interactive/scivisStudio/Dataset.h | 8 +++- tsd/apps/interactive/scivisStudio/Project.cpp | 4 ++ tsd/apps/interactive/scivisStudio/Project.h | 4 ++ .../scivisStudio/ProjectContext.cpp | 29 +++++++------- .../scivisStudio/ProjectSerialization.cpp | 15 +++---- .../interactive/scivisStudio/RenderShot.cpp | 3 +- tsd/apps/interactive/scivisStudio/Shot.cpp | 4 +- tsd/apps/interactive/scivisStudio/Shot.h | 4 ++ .../scivisStudio/ShotCameraRig.cpp | 6 +-- .../interactive/scivisStudio/ShotCameraRig.h | 6 ++- .../scivisStudio/windows/CameraRigEditor.cpp | 13 ++++--- .../scivisStudio/windows/DatasetEditor.cpp | 4 +- .../scivisStudio/windows/LightRigEditor.cpp | 5 ++- .../scivisStudio/windows/ProjectWindow.cpp | 4 +- .../scivisStudio/windows/ShotEditor.cpp | 11 +++--- tsd/tests/test_SciVisStudio.cpp | 39 ++++++++++--------- 18 files changed, 105 insertions(+), 73 deletions(-) diff --git a/tsd/apps/interactive/scivisStudio/Application.cpp b/tsd/apps/interactive/scivisStudio/Application.cpp index 9684068ef..7f8ec3c14 100644 --- a/tsd/apps/interactive/scivisStudio/Application.cpp +++ b/tsd/apps/interactive/scivisStudio/Application.cpp @@ -31,6 +31,7 @@ namespace tsd::scivis_studio { + using TSDApplication = tsd::ui::imgui::Application; namespace tsd_ui = tsd::ui::imgui; @@ -227,7 +228,7 @@ void Application::restoreViewportFromActiveShot() if (!m_viewport) return; - const auto *shot = activeShot(m_projectContext.project()); + const auto *shot = project::activeShot(m_projectContext.project()); if (!shot) { m_viewport->setLibraryToDefault(); return; @@ -243,7 +244,7 @@ void Application::syncActiveShotRenderSettingsFromViewport() return; auto &project = m_projectContext.project(); - auto *shot = activeShot(project); + auto *shot = project::activeShot(project); if (!shot) return; @@ -326,7 +327,7 @@ bool Application::openProject(const std::filesystem::path &directory) return false; } - if (const auto *shot = activeShot(m_projectContext.project())) { + if (const auto *shot = project::activeShot(m_projectContext.project())) { auto &viewportSettings = scratch.root()["windows"]["Viewport"]; viewportSettings["anariLibrary"] = shot->renderSettings.rendererLibrary; viewportSettings["rendererObjectIndex"] = @@ -612,7 +613,7 @@ void Application::uiFrameStart() const ImGuiIO &io = ImGui::GetIO(); auto &animMgr = appContext()->tsd.animationMgr; animMgr.tick(io.DeltaTime); - if (auto *shot = activeShot(m_projectContext.project())) + if (auto *shot = project::activeShot(m_projectContext.project())) shot->playing = animMgr.isPlaying(); if (ImGui::BeginMainMenuBar()) { @@ -646,7 +647,7 @@ void Application::uiFrameStart() } if (!io.WantTextInput && ImGui::IsKeyPressed(ImGuiKey_Space)) { - if (auto *shot = activeShot(m_projectContext.project())) { + if (auto *shot = project::activeShot(m_projectContext.project())) { animMgr.togglePlay(); shot->playing = animMgr.isPlaying(); } diff --git a/tsd/apps/interactive/scivisStudio/Dataset.cpp b/tsd/apps/interactive/scivisStudio/Dataset.cpp index 95e70548b..46e51d2e6 100644 --- a/tsd/apps/interactive/scivisStudio/Dataset.cpp +++ b/tsd/apps/interactive/scivisStudio/Dataset.cpp @@ -3,7 +3,7 @@ #include "Dataset.h" -namespace tsd::scivis_studio { +namespace tsd::scivis_studio::dataset { const char *toString(DatasetSourceKind kind) { @@ -33,7 +33,7 @@ const char *toString(DatasetStatus status) return "Missing"; } -DatasetSourceKind datasetSourceKindFromString(const std::string &s) +DatasetSourceKind sourceKindFromString(const std::string &s) { if (s == "TimeSeries") return DatasetSourceKind::TimeSeries; @@ -42,7 +42,7 @@ DatasetSourceKind datasetSourceKindFromString(const std::string &s) return DatasetSourceKind::Static; } -DatasetStatus datasetStatusFromString(const std::string &s) +DatasetStatus statusFromString(const std::string &s) { if (s == "Available") return DatasetStatus::Available; @@ -53,4 +53,4 @@ DatasetStatus datasetStatusFromString(const std::string &s) return DatasetStatus::Missing; } -} // namespace tsd::scivis_studio +} // namespace tsd::scivis_studio::dataset diff --git a/tsd/apps/interactive/scivisStudio/Dataset.h b/tsd/apps/interactive/scivisStudio/Dataset.h index ca15c1cd0..eeceacf1a 100644 --- a/tsd/apps/interactive/scivisStudio/Dataset.h +++ b/tsd/apps/interactive/scivisStudio/Dataset.h @@ -63,9 +63,13 @@ struct Dataset SceneNodeRef rootNode; }; +namespace dataset { + const char *toString(DatasetSourceKind kind); const char *toString(DatasetStatus status); -DatasetSourceKind datasetSourceKindFromString(const std::string &s); -DatasetStatus datasetStatusFromString(const std::string &s); +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/Project.cpp b/tsd/apps/interactive/scivisStudio/Project.cpp index ea0998e38..7d6cdb75c 100644 --- a/tsd/apps/interactive/scivisStudio/Project.cpp +++ b/tsd/apps/interactive/scivisStudio/Project.cpp @@ -24,6 +24,8 @@ void Project::markClean() dirty = false; } +namespace project { + std::string makeGeneratedId(const char *prefix, size_t ordinal) { std::ostringstream ss; @@ -113,4 +115,6 @@ const Shot *activeShot(const Project &project) 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 index 0d7db71c5..ca72bf056 100644 --- a/tsd/apps/interactive/scivisStudio/Project.h +++ b/tsd/apps/interactive/scivisStudio/Project.h @@ -41,6 +41,8 @@ struct Project void markClean(); }; +namespace project { + std::string makeGeneratedId(const char *prefix, size_t ordinal); DatasetID nextDatasetId(const Project &project); ShotID nextShotId(const Project &project); @@ -56,4 +58,6 @@ 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 index 34f109442..7d56af53e 100644 --- a/tsd/apps/interactive/scivisStudio/ProjectContext.cpp +++ b/tsd/apps/interactive/scivisStudio/ProjectContext.cpp @@ -18,6 +18,7 @@ namespace tsd::scivis_studio { + static tsd::scene::LayerNodeRef findDirectChild( tsd::scene::LayerNodeRef parent, const std::string &name) { @@ -206,7 +207,7 @@ LightRig *ProjectContext::createLightRig(const std::string &name) return nullptr; LightRig rig; - rig.id = nextLightRigId(m_project); + rig.id = project::nextLightRigId(m_project); rig.name = name.empty() ? ("Light Rig " + std::to_string(m_project.lightRigs.size() + 1)) : name; @@ -338,7 +339,7 @@ void ProjectContext::createUnsavedProject() (void)datasetsRoot; Shot shot; - shot.id = nextShotId(m_project); + shot.id = project::nextShotId(m_project); shot.name = "Shot 1"; shot.renderSettings.outputFilePrefix = shot.id; ensureRendererDefaults(shot); @@ -348,7 +349,7 @@ void ProjectContext::createUnsavedProject() camera->setName(shot.id + "_camera"); shot.camera = {ANARI_CAMERA, camera.index()}; shot.cameraRig.current = - manipulatorStateFromManipulator(m_ctx->view.manipulator); + shot_camera_rig::manipulatorStateFromManipulator(m_ctx->view.manipulator); tsd::rendering::updateCameraObject(*camera, m_ctx->view.manipulator); ensureChild(shotsRoot, shot.id.c_str()); @@ -368,7 +369,7 @@ bool ProjectContext::addShot(const std::string &name) return false; Shot shot; - shot.id = nextShotId(m_project); + shot.id = project::nextShotId(m_project); shot.name = name.empty() ? ("Shot " + std::to_string(m_project.shots.size() + 1)) : name; @@ -385,7 +386,7 @@ bool ProjectContext::addShot(const std::string &name) camera->setName(shot.id + "_camera"); shot.camera = {ANARI_CAMERA, camera.index()}; shot.cameraRig.current = - manipulatorStateFromManipulator(m_ctx->view.manipulator); + shot_camera_rig::manipulatorStateFromManipulator(m_ctx->view.manipulator); tsd::rendering::updateCameraObject(*camera, m_ctx->view.manipulator); ensureChild(ensureShotsRoot(), shot.id.c_str()); @@ -435,7 +436,7 @@ Dataset *ProjectContext::addStaticDataset(const std::string &name, return nullptr; Dataset dataset; - dataset.id = nextDatasetId(m_project); + dataset.id = project::nextDatasetId(m_project); dataset.name = name.empty() ? dataset.id : name; dataset.sourceKind = DatasetSourceKind::Static; dataset.importerType = toString(importerType); @@ -457,7 +458,7 @@ Dataset *ProjectContext::addStaticDataset(const std::string &name, datasetRoot); record.status = DatasetStatus::Available; for (auto &shot : m_project.shots) - setDatasetBinding(shot, record.id, &shot == activeShot(m_project)); + 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", @@ -480,7 +481,7 @@ void ProjectContext::applyActiveShot() if (!m_ctx) return; - auto *shot = activeShot(m_project); + auto *shot = project::activeShot(m_project); if (!shot) return; @@ -505,7 +506,7 @@ void ProjectContext::applyActiveShot() for (auto &dataset : m_project.datasets) { bool enabled = false; - if (const auto *binding = findDatasetBinding(*shot, dataset.id)) + if (const auto *binding = shot::findDatasetBinding(*shot, dataset.id)) enabled = binding->enabled; setNodeEnabled(resolveDatasetRoot(dataset), enabled); } @@ -513,8 +514,8 @@ void ProjectContext::applyActiveShot() for (auto *layer : changedLayers) m_ctx->tsd.scene.signalLayerStructureChanged(layer); - auto sampled = sampleCameraRig(shot->cameraRig, shot->currentFrame); - applyManipulatorState(m_ctx->view.manipulator, sampled); + 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); @@ -527,7 +528,7 @@ void ProjectContext::syncAnimationManagerToActiveShot() if (!m_ctx) return; - auto *shot = activeShot(m_project); + auto *shot = project::activeShot(m_project); if (!shot) return; @@ -555,7 +556,7 @@ void ProjectContext::updateActiveShotFromAnimationTime() if (!m_ctx || m_syncingAnimationManager) return; - auto *shot = activeShot(m_project); + auto *shot = project::activeShot(m_project); if (!shot) return; @@ -756,7 +757,7 @@ void ProjectContext::migrateLegacyShotLightsToLightRigs() continue; LightRig rig; - rig.id = nextLightRigId(m_project); + rig.id = project::nextLightRigId(m_project); rig.name = shot.name.empty() ? (shot.id + " Lights") : (shot.name + " Lights"); if (auto existing = findDirectChild(lightRigsRoot, rig.id)) diff --git a/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp b/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp index 8077bcb7a..d2def179c 100644 --- a/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp +++ b/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp @@ -31,7 +31,8 @@ static void cameraRigToNode(const ShotCameraRig &rig, tsd::core::DataNode &node) auto &kf = keyframes.append(); kf["frame"] = keyframe.frame; kf["name"] = keyframe.name; - kf["interpolationToNext"] = toString(keyframe.interpolationToNext); + kf["interpolationToNext"] = + shot_camera_rig::toString(keyframe.interpolationToNext); manipulatorStateToNode(keyframe.manipulator, kf["manipulator"]); } } @@ -47,14 +48,14 @@ static void nodeToCameraRig(tsd::core::DataNode &node, ShotCameraRig &rig) CameraKeyframe keyframe; keyframe.frame = kf["frame"].getValueOr(0); keyframe.name = kf["name"].getValueOr(""); - keyframe.interpolationToNext = cameraInterpolationFromString( + 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)); }); } - sortKeyframes(rig); + shot_camera_rig::sortKeyframes(rig); } void projectToNode(const Project &project, tsd::core::DataNode &node) @@ -70,9 +71,9 @@ void projectToNode(const Project &project, tsd::core::DataNode &node) auto &d = datasets.append(); d["id"] = dataset.id; d["name"] = dataset.name; - d["sourceKind"] = toString(dataset.sourceKind); + d["sourceKind"] = dataset::toString(dataset.sourceKind); d["importerType"] = dataset.importerType; - d["status"] = toString(dataset.status); + d["status"] = dataset::toString(dataset.status); auto &source = d["source"]; source["absolutePath"] = dataset.source.absolutePath; @@ -140,10 +141,10 @@ bool nodeToProject(tsd::core::DataNode &node, Project &project) Dataset dataset; dataset.id = d["id"].getValueOr(""); dataset.name = d["name"].getValueOr(dataset.id); - dataset.sourceKind = datasetSourceKindFromString( + dataset.sourceKind = dataset::sourceKindFromString( d["sourceKind"].getValueOr("Static")); dataset.importerType = d["importerType"].getValueOr("NONE"); - dataset.status = datasetStatusFromString( + dataset.status = dataset::statusFromString( d["status"].getValueOr("Missing")); if (auto *source = d.child("source")) { diff --git a/tsd/apps/interactive/scivisStudio/RenderShot.cpp b/tsd/apps/interactive/scivisStudio/RenderShot.cpp index 0849aa22b..e52cef23e 100644 --- a/tsd/apps/interactive/scivisStudio/RenderShot.cpp +++ b/tsd/apps/interactive/scivisStudio/RenderShot.cpp @@ -16,6 +16,7 @@ namespace tsd::scivis_studio { + namespace { anari::Device loadFirstAvailableDevice( @@ -54,7 +55,7 @@ bool renderActiveShotToFrames( ProjectContext &projectContext, RenderShotProgress *progress) { auto *ctx = projectContext.appContext(); - auto *shot = activeShot(projectContext.project()); + auto *shot = project::activeShot(projectContext.project()); if (!ctx || !shot) return false; diff --git a/tsd/apps/interactive/scivisStudio/Shot.cpp b/tsd/apps/interactive/scivisStudio/Shot.cpp index 0e71c18ac..a0af2d23f 100644 --- a/tsd/apps/interactive/scivisStudio/Shot.cpp +++ b/tsd/apps/interactive/scivisStudio/Shot.cpp @@ -5,7 +5,7 @@ #include -namespace tsd::scivis_studio { +namespace tsd::scivis_studio::shot { DatasetBinding *findDatasetBinding(Shot &shot, const DatasetID &id) { @@ -33,4 +33,4 @@ void setDatasetBinding(Shot &shot, const DatasetID &id, bool enabled) shot.datasetBindings.push_back({id, enabled}); } -} // namespace tsd::scivis_studio +} // namespace tsd::scivis_studio::shot diff --git a/tsd/apps/interactive/scivisStudio/Shot.h b/tsd/apps/interactive/scivisStudio/Shot.h index 68f5ee5c5..aa8a89856 100644 --- a/tsd/apps/interactive/scivisStudio/Shot.h +++ b/tsd/apps/interactive/scivisStudio/Shot.h @@ -45,8 +45,12 @@ struct Shot 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 index 95e565000..7384cad55 100644 --- a/tsd/apps/interactive/scivisStudio/ShotCameraRig.cpp +++ b/tsd/apps/interactive/scivisStudio/ShotCameraRig.cpp @@ -8,7 +8,7 @@ #include #include -namespace tsd::scivis_studio { +namespace tsd::scivis_studio::shot_camera_rig { const char *toString(CameraInterpolation interpolation) { @@ -27,7 +27,7 @@ const char *toString(CameraInterpolation interpolation) return "Linear"; } -CameraInterpolation cameraInterpolationFromString(const std::string &s) +CameraInterpolation interpolationFromString(const std::string &s) { if (s == "Hold") return CameraInterpolation::Hold; @@ -147,4 +147,4 @@ ManipulatorState sampleCameraRig(const ShotCameraRig &rig, int frame) return keyframes.back().manipulator; } -} // namespace tsd::scivis_studio +} // namespace tsd::scivis_studio::shot_camera_rig diff --git a/tsd/apps/interactive/scivisStudio/ShotCameraRig.h b/tsd/apps/interactive/scivisStudio/ShotCameraRig.h index c373d9e4c..89eb8f4a4 100644 --- a/tsd/apps/interactive/scivisStudio/ShotCameraRig.h +++ b/tsd/apps/interactive/scivisStudio/ShotCameraRig.h @@ -38,8 +38,10 @@ struct ShotCameraRig std::vector keyframes; }; +namespace shot_camera_rig { + const char *toString(CameraInterpolation interpolation); -CameraInterpolation cameraInterpolationFromString(const std::string &s); +CameraInterpolation interpolationFromString(const std::string &s); ManipulatorState manipulatorStateFromManipulator( const tsd::rendering::Manipulator &m); @@ -49,4 +51,6 @@ void applyManipulatorState( 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/windows/CameraRigEditor.cpp b/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp index c30f61f7f..78cbf3930 100644 --- a/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp +++ b/tsd/apps/interactive/scivisStudio/windows/CameraRigEditor.cpp @@ -13,6 +13,7 @@ namespace tsd::scivis_studio { + namespace { int cameraInterpolationIndex(CameraInterpolation interpolation) @@ -63,7 +64,7 @@ void CameraRigEditor::buildUI() return; auto &project = m_projectContext->project(); - auto *shot = activeShot(project); + auto *shot = project::activeShot(project); if (!shot) { ImGui::TextDisabled("No active shot"); return; @@ -73,7 +74,7 @@ void CameraRigEditor::buildUI() auto &rig = shot->cameraRig; if (ImGui::Button("Set View")) { - rig.current = manipulatorStateFromManipulator(ctx->view.manipulator); + rig.current = shot_camera_rig::manipulatorStateFromManipulator(ctx->view.manipulator); project.markDirty(); } tsd::ui::tooltipForPreviousItem("Set Rig View From Viewport"); @@ -85,9 +86,9 @@ void CameraRigEditor::buildUI() keyframe.frame = shot->currentFrame; keyframe.name = "Frame " + std::to_string(shot->currentFrame); keyframe.manipulator = - manipulatorStateFromManipulator(ctx->view.manipulator); + shot_camera_rig::manipulatorStateFromManipulator(ctx->view.manipulator); rig.keyframes.push_back(std::move(keyframe)); - sortKeyframes(rig); + shot_camera_rig::sortKeyframes(rig); m_selectedKeyframe = static_cast(rig.keyframes.size()) - 1; project.markDirty(); } @@ -104,7 +105,7 @@ void CameraRigEditor::buildUI() ImGui::BeginDisabled(!hasSelection); if (ImGui::Button("Update")) { rig.keyframes[m_selectedKeyframe].manipulator = - manipulatorStateFromManipulator(ctx->view.manipulator); + shot_camera_rig::manipulatorStateFromManipulator(ctx->view.manipulator); project.markDirty(); } tsd::ui::tooltipForPreviousItem("Update Selected From Viewport"); @@ -162,7 +163,7 @@ void CameraRigEditor::buildUI() ImGui::TableNextColumn(); if (ImGui::InputInt("##frame", &keyframe.frame)) { - sortKeyframes(rig); + shot_camera_rig::sortKeyframes(rig); project.markDirty(); } diff --git a/tsd/apps/interactive/scivisStudio/windows/DatasetEditor.cpp b/tsd/apps/interactive/scivisStudio/windows/DatasetEditor.cpp index 739d4b2cd..594d8b6fb 100644 --- a/tsd/apps/interactive/scivisStudio/windows/DatasetEditor.cpp +++ b/tsd/apps/interactive/scivisStudio/windows/DatasetEditor.cpp @@ -42,8 +42,8 @@ void DatasetEditor::buildUI() auto &dataset = datasets[m_selectedDataset]; ImGui::Text("ID: %s", dataset.id.c_str()); - ImGui::Text("Status: %s", toString(dataset.status)); - ImGui::Text("Source kind: %s", toString(dataset.sourceKind)); + 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()); ImGui::Text("Root: %s/%zu", diff --git a/tsd/apps/interactive/scivisStudio/windows/LightRigEditor.cpp b/tsd/apps/interactive/scivisStudio/windows/LightRigEditor.cpp index 897638504..2fda290c9 100644 --- a/tsd/apps/interactive/scivisStudio/windows/LightRigEditor.cpp +++ b/tsd/apps/interactive/scivisStudio/windows/LightRigEditor.cpp @@ -15,6 +15,7 @@ namespace tsd::scivis_studio { + namespace { struct LightTypeOption @@ -244,7 +245,7 @@ void LightRigEditor::buildUI() if (inputText("Name", rig.name)) project.markDirty(); - auto *shot = activeShot(project); + 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) { @@ -268,7 +269,7 @@ void LightRigEditor::buildUI() if (ImGui::BeginPopupModal( "Delete Light Rig?", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { - auto *pending = findLightRig(project, m_pendingDeleteRig); + 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(), diff --git a/tsd/apps/interactive/scivisStudio/windows/ProjectWindow.cpp b/tsd/apps/interactive/scivisStudio/windows/ProjectWindow.cpp index 56a6ef1bf..1474d5998 100644 --- a/tsd/apps/interactive/scivisStudio/windows/ProjectWindow.cpp +++ b/tsd/apps/interactive/scivisStudio/windows/ProjectWindow.cpp @@ -32,7 +32,9 @@ void ProjectWindow::buildUI() ImGui::TextDisabled("No datasets"); for (const auto &dataset : project.datasets) ImGui::BulletText( - "%s [%s]", dataset.name.c_str(), toString(dataset.status)); + "%s [%s]", + dataset.name.c_str(), + dataset::toString(dataset.status)); ImGui::SeparatorText("Shots"); for (auto &shot : project.shots) { diff --git a/tsd/apps/interactive/scivisStudio/windows/ShotEditor.cpp b/tsd/apps/interactive/scivisStudio/windows/ShotEditor.cpp index 7b3929c57..1615af696 100644 --- a/tsd/apps/interactive/scivisStudio/windows/ShotEditor.cpp +++ b/tsd/apps/interactive/scivisStudio/windows/ShotEditor.cpp @@ -15,6 +15,7 @@ namespace tsd::scivis_studio { + namespace { constexpr const char *NO_RENDERERS_LABEL = ""; @@ -162,7 +163,7 @@ void ShotEditor::buildUI_lightRigSelector(Shot &shot) auto &project = m_projectContext->project(); std::string preview = "None"; if (!shot.lightRigId.empty()) { - if (auto *rig = findLightRig(project, shot.lightRigId)) + if (auto *rig = project::findLightRig(project, shot.lightRigId)) preview = rig->name; else preview = ""; @@ -193,7 +194,7 @@ void ShotEditor::buildUI_lightRigSelector(Shot &shot) ImGui::SetItemDefaultFocus(); } - if (!shot.lightRigId.empty() && !findLightRig(project, shot.lightRigId)) { + if (!shot.lightRigId.empty() && !project::findLightRig(project, shot.lightRigId)) { const auto missing = ""; ImGui::TextDisabled("%s", missing.c_str()); } @@ -207,7 +208,7 @@ void ShotEditor::buildUI() return; auto &project = m_projectContext->project(); - auto *shot = activeShot(project); + auto *shot = project::activeShot(project); if (!shot) { ImGui::TextDisabled("No active shot"); return; @@ -287,10 +288,10 @@ void ShotEditor::buildUI() ImGui::SeparatorText("Datasets"); for (const auto &dataset : project.datasets) { bool enabled = true; - if (auto *binding = findDatasetBinding(*shot, dataset.id)) + if (auto *binding = shot::findDatasetBinding(*shot, dataset.id)) enabled = binding->enabled; if (ImGui::Checkbox(dataset.name.c_str(), &enabled)) { - setDatasetBinding(*shot, dataset.id, enabled); + shot::setDatasetBinding(*shot, dataset.id, enabled); project.markDirty(); m_projectContext->applyActiveShot(); } diff --git a/tsd/tests/test_SciVisStudio.cpp b/tsd/tests/test_SciVisStudio.cpp index e34d3a161..fdf326077 100644 --- a/tsd/tests/test_SciVisStudio.cpp +++ b/tsd/tests/test_SciVisStudio.cpp @@ -19,6 +19,7 @@ using namespace tsd::scivis_studio; + namespace { struct CountingLayerUpdateDelegate : public tsd::scene::EmptyUpdateDelegate @@ -130,9 +131,11 @@ SCENARIO("SciVis Studio camera interpolation modes", "[SciVisStudio]") CameraInterpolation::EaseOutIn}; for (auto mode : modes) - REQUIRE(cameraInterpolationFromString(toString(mode)) == mode); + REQUIRE(shot_camera_rig::interpolationFromString( + shot_camera_rig::toString(mode)) + == mode); - REQUIRE(cameraInterpolationFromString("Unknown") + REQUIRE(shot_camera_rig::interpolationFromString("Unknown") == CameraInterpolation::Linear); } @@ -155,17 +158,17 @@ SCENARIO("SciVis Studio camera interpolation modes", "[SciVisStudio]") rig.keyframes = {a, b}; rig.keyframes.front().interpolationToNext = CameraInterpolation::EaseOut; - REQUIRE(sampleCameraRig(rig, 25).orbit.lookat.x == Approx(6.25f)); + REQUIRE(shot_camera_rig::sampleCameraRig(rig, 25).orbit.lookat.x == Approx(6.25f)); rig.keyframes.front().interpolationToNext = CameraInterpolation::EaseIn; - REQUIRE(sampleCameraRig(rig, 25).orbit.lookat.x == Approx(57.8125f)); + REQUIRE(shot_camera_rig::sampleCameraRig(rig, 25).orbit.lookat.x == Approx(57.8125f)); rig.keyframes.front().interpolationToNext = CameraInterpolation::EaseOutIn; - REQUIRE(sampleCameraRig(rig, 25).orbit.lookat.x == Approx(10.3515625f)); - REQUIRE(sampleCameraRig(rig, 25).orbit.azeldist.x == Approx(10.3515625f)); - REQUIRE(sampleCameraRig(rig, 25).orbit.fixedDist == Approx(10.3515625f)); - REQUIRE(sampleCameraRig(rig, 75).orbit.lookat.x == Approx(89.6484375f)); + 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)); } } } @@ -291,7 +294,7 @@ SCENARIO("SciVis Studio new shots use the default light rig", "[SciVisStudio]") const auto defaultRigId = projectContext.project().lightRigs.front().id; REQUIRE(projectContext.addShot()); - REQUIRE(activeShot(projectContext.project())->lightRigId == defaultRigId); + REQUIRE(project::activeShot(projectContext.project())->lightRigId == defaultRigId); } SCENARIO("SciVis Studio shot dataset bindings update scene visibility", @@ -317,8 +320,8 @@ SCENARIO("SciVis Studio shot dataset bindings update scene visibility", DatasetStatus::Available, projectContext.refFor("studio", datasetRoot)}); - auto &shot = *activeShot(project); - setDatasetBinding(shot, "dataset_0001", false); + auto &shot = *project::activeShot(project); + shot::setDatasetBinding(shot, "dataset_0001", false); auto *delegate = scene.updateDelegate().emplace(); @@ -356,8 +359,8 @@ SCENARIO("SciVis Studio dataset binding resolves the dataset group by ID", DatasetStatus::Available, projectContext.refFor("studio", partRoot)}); - auto &shot = *activeShot(project); - setDatasetBinding(shot, "dataset_0001", false); + auto &shot = *project::activeShot(project); + shot::setDatasetBinding(shot, "dataset_0001", false); projectContext.applyActiveShot(); @@ -395,7 +398,7 @@ SCENARIO("SciVis Studio saved projects rebuild runtime refs from stable IDs", {}, DatasetStatus::Available, projectContext.refFor("studio", datasetRoot)}); - setDatasetBinding(*activeShot(project), "dataset_0001", false); + shot::setDatasetBinding(*project::activeShot(project), "dataset_0001", false); REQUIRE(projectContext.saveProject(root)); } @@ -444,7 +447,7 @@ SCENARIO( auto &project = projectContext.project(); auto &firstShot = project.shots.front(); - auto *defaultRig = findLightRig(project, firstShot.lightRigId); + auto *defaultRig = project::findLightRig(project, firstShot.lightRigId); REQUIRE(defaultRig != nullptr); auto defaultRoot = projectContext.resolveLightRigRoot(*defaultRig); REQUIRE(defaultRoot); @@ -455,7 +458,7 @@ SCENARIO( REQUIRE(secondRoot); projectContext.addShot("Second Shot"); - auto &secondShot = *activeShot(project); + auto &secondShot = *project::activeShot(project); secondShot.lightRigId = secondRig->id; projectContext.applyActiveShot(); @@ -482,7 +485,7 @@ SCENARIO("SciVis Studio removing a light rig clears shot references", auto &project = projectContext.project(); const auto rigId = project.lightRigs.front().id; - auto *rig = findLightRig(project, rigId); + auto *rig = project::findLightRig(project, rigId); REQUIRE(rig != nullptr); auto root = projectContext.resolveLightRigRoot(*rig); REQUIRE(root); @@ -559,7 +562,7 @@ SCENARIO("SciVis Studio shot time is driven by the animation manager", ProjectContext projectContext(&appContext); projectContext.createUnsavedProject(); - auto &shot = *activeShot(projectContext.project()); + auto &shot = *project::activeShot(projectContext.project()); shot.frameCount = 24; shot.fps = 12.f; shot.currentFrame = 4; From 3d654652b18cb633f558b92913e19bcfb71fc49b Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 29 May 2026 13:37:38 -0600 Subject: [PATCH 44/49] decouple viewport renderer from shot renderer --- .../interactive/scivisStudio/Application.cpp | 78 +++---------------- .../interactive/scivisStudio/Application.h | 4 +- 2 files changed, 10 insertions(+), 72 deletions(-) diff --git a/tsd/apps/interactive/scivisStudio/Application.cpp b/tsd/apps/interactive/scivisStudio/Application.cpp index 7f8ec3c14..90889f282 100644 --- a/tsd/apps/interactive/scivisStudio/Application.cpp +++ b/tsd/apps/interactive/scivisStudio/Application.cpp @@ -180,12 +180,12 @@ tsd::ui::imgui::WindowArray Application::setupWindows() if (!m_initialProjectDirectory.empty()) { if (!openProject(m_initialProjectDirectory)) { m_projectContext.createUnsavedProject(); - m_keepBlankProjectCleanAfterViewportSync = true; + m_keepBlankProjectCleanAfterViewportSetup = true; m_viewport->setLibraryToDefault(); } } else { m_projectContext.createUnsavedProject(); - m_keepBlankProjectCleanAfterViewportSync = true; + m_keepBlankProjectCleanAfterViewportSetup = true; m_viewport->setLibraryToDefault(); } @@ -223,58 +223,6 @@ void Application::loadLayout(const std::string &layout) ImGui::LoadIniSettingsFromMemory(layout.c_str()); } -void Application::restoreViewportFromActiveShot() -{ - if (!m_viewport) - return; - - const auto *shot = project::activeShot(m_projectContext.project()); - if (!shot) { - m_viewport->setLibraryToDefault(); - return; - } - - m_viewport->setLibrary(shot->renderSettings.rendererLibrary, - shot->renderSettings.rendererObjectIndex); -} - -void Application::syncActiveShotRenderSettingsFromViewport() -{ - if (!m_viewport) - return; - - auto &project = m_projectContext.project(); - auto *shot = project::activeShot(project); - if (!shot) - return; - - const auto &libraryName = m_viewport->libraryName(); - const auto rendererIndex = m_viewport->currentRendererObjectIndex(); - if (libraryName.empty() || rendererIndex == TSD_INVALID_INDEX) - return; - - bool changed = false; - if (shot->renderSettings.rendererLibrary != libraryName) { - shot->renderSettings.rendererLibrary = libraryName; - changed = true; - } - if (shot->renderSettings.rendererObjectIndex != rendererIndex) { - shot->renderSettings.rendererObjectIndex = rendererIndex; - changed = true; - } - - auto *renderer = - appContext()->tsd.scene.getObject(ANARI_RENDERER, rendererIndex); - if (renderer - && shot->renderSettings.rendererSubtype != renderer->subtype().str()) { - shot->renderSettings.rendererSubtype = renderer->subtype().str(); - changed = true; - } - - if (changed) - project.markDirty(); -} - bool Application::saveProject() { auto &project = m_projectContext.project(); @@ -288,8 +236,6 @@ bool Application::saveProject() bool Application::saveProjectAs(const std::filesystem::path &directory) { - syncActiveShotRenderSettingsFromViewport(); - tsd::core::DataTree scratch; auto &root = scratch.root(); saveWindowSettings(root["windows"]); @@ -323,17 +269,11 @@ bool Application::openProject(const std::filesystem::path &directory) &error); if (!ok) { tsd::core::logError("[SciVisStudio] Open failed: %s", error.c_str()); - restoreViewportFromActiveShot(); + if (m_viewport) + m_viewport->setLibraryToDefault(); return false; } - if (const auto *shot = project::activeShot(m_projectContext.project())) { - auto &viewportSettings = scratch.root()["windows"]["Viewport"]; - viewportSettings["anariLibrary"] = shot->renderSettings.rendererLibrary; - viewportSettings["rendererObjectIndex"] = - static_cast(shot->renderSettings.rendererObjectIndex); - } - loadWindowSettings(scratch.root()["windows"]); loadLayout(layout); loadApplicationSettings(scratch.root()); @@ -347,8 +287,9 @@ void Application::newProject() m_viewport->releaseSceneReferences(); m_projectContext.createUnsavedProject(); - m_keepBlankProjectCleanAfterViewportSync = true; - restoreViewportFromActiveShot(); + m_keepBlankProjectCleanAfterViewportSetup = true; + if (m_viewport) + m_viewport->setLibraryToDefault(); } void Application::requestDirtyAction(PendingDirtyAction action) @@ -659,11 +600,10 @@ void Application::uiFrameStart() if (!modalActive && ImGui::IsKeyChordPressed(ImGuiKey_Escape)) appContext()->clearSelected(); - syncActiveShotRenderSettingsFromViewport(); - if (m_keepBlankProjectCleanAfterViewportSync + if (m_keepBlankProjectCleanAfterViewportSetup && (!m_taskModal || !m_taskModal->visible())) { m_projectContext.project().markClean(); - m_keepBlankProjectCleanAfterViewportSync = false; + m_keepBlankProjectCleanAfterViewportSetup = false; } } diff --git a/tsd/apps/interactive/scivisStudio/Application.h b/tsd/apps/interactive/scivisStudio/Application.h index 644d9bae2..02c77c172 100644 --- a/tsd/apps/interactive/scivisStudio/Application.h +++ b/tsd/apps/interactive/scivisStudio/Application.h @@ -82,8 +82,6 @@ class Application : public tsd::ui::imgui::Application void loadWindowSettings(tsd::core::DataNode &node); std::string saveLayout() const; void loadLayout(const std::string &layout); - void restoreViewportFromActiveShot(); - void syncActiveShotRenderSettingsFromViewport(); void requestDirtyAction(PendingDirtyAction action); void requestOpenRecentProject(const std::filesystem::path &directory); void continueDirtyAction(); @@ -101,7 +99,7 @@ class Application : public tsd::ui::imgui::Application std::vector m_recentProjects; PendingDirtyAction m_pendingDirtyAction{PendingDirtyAction::None}; bool m_viewportRenderingDisabledForShotRender{false}; - bool m_keepBlankProjectCleanAfterViewportSync{false}; + bool m_keepBlankProjectCleanAfterViewportSetup{false}; tsd::ui::imgui::Viewport *m_viewport{nullptr}; tsd::ui::imgui::LayerTree *m_layerTree{nullptr}; From d39db6a5a029e3982f059925603a366269c9841a Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Fri, 29 May 2026 17:14:00 -0600 Subject: [PATCH 45/49] fix SRT <--> mat4 transform conversion --- tsd/src/tsd/core/TSDMath.hpp | 39 +++++++++++++--- tsd/src/tsd/scene/LayerNodeData.cpp | 2 +- tsd/tests/test_Math.cpp | 69 +++++++++++++++++++++++++++++ tsd/tests/test_Scene.cpp | 43 ++++++++++++++++++ 4 files changed, 145 insertions(+), 8 deletions(-) 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/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/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]") { From 8408b173aa6183274fd25ef8eda47ab0c2a91e21 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Sat, 30 May 2026 18:32:50 -0600 Subject: [PATCH 46/49] rename AddDatasetDialog --> AddStaticDatasetDialog --- .../interactive/scivisStudio/Application.cpp | 18 +++++++++--------- .../interactive/scivisStudio/Application.h | 6 +++--- .../interactive/scivisStudio/CMakeLists.txt | 2 +- .../scivisStudio/default_ui_layout.txt | 3 +-- ...etDialog.cpp => AddStaticDatasetDialog.cpp} | 10 +++++----- ...atasetDialog.h => AddStaticDatasetDialog.h} | 6 +++--- 6 files changed, 22 insertions(+), 23 deletions(-) rename tsd/apps/interactive/scivisStudio/modals/{AddDatasetDialog.cpp => AddStaticDatasetDialog.cpp} (93%) rename tsd/apps/interactive/scivisStudio/modals/{AddDatasetDialog.h => AddStaticDatasetDialog.h} (81%) diff --git a/tsd/apps/interactive/scivisStudio/Application.cpp b/tsd/apps/interactive/scivisStudio/Application.cpp index 90889f282..198f0af5a 100644 --- a/tsd/apps/interactive/scivisStudio/Application.cpp +++ b/tsd/apps/interactive/scivisStudio/Application.cpp @@ -5,7 +5,7 @@ #include "DefaultLayout.h" #include "RenderShot.h" -#include "modals/AddDatasetDialog.h" +#include "modals/AddStaticDatasetDialog.h" #include "modals/ProjectLocationDialog.h" #include "windows/CameraRigEditor.h" #include "windows/DatasetEditor.h" @@ -174,8 +174,8 @@ tsd::ui::imgui::WindowArray Application::setupWindows() m_transferFunctionEditor->hide(); m_projectLocationDialog = std::make_unique(this); - m_addDatasetDialog = - std::make_unique(this, &m_projectContext); + m_addStaticDatasetDialog = + std::make_unique(this, &m_projectContext); if (!m_initialProjectDirectory.empty()) { if (!openProject(m_initialProjectDirectory)) { @@ -471,9 +471,9 @@ void Application::uiRecentProjectsMenu() clearRecentProjects(); } -void Application::showAddDatasetDialog() +void Application::showAddStaticDatasetDialog() { - m_addDatasetDialog->show(); + m_addStaticDatasetDialog->show(); } void Application::showProjectLocationDialogForOpen() @@ -582,8 +582,8 @@ void Application::uiFrameStart() modalActive = renderConfirmationModal(m_confirmationModal) || modalActive; - if (m_addDatasetDialog && m_addDatasetDialog->visible()) { - m_addDatasetDialog->renderUI(); + if (m_addStaticDatasetDialog && m_addStaticDatasetDialog->visible()) { + m_addStaticDatasetDialog->renderUI(); modalActive = true; } @@ -628,8 +628,8 @@ void Application::uiMainMenuBar() } if (ImGui::BeginMenu("Studio")) { - if (ImGui::MenuItem("Add Dataset...")) - showAddDatasetDialog(); + if (ImGui::MenuItem("Add Static Dataset...")) + showAddStaticDatasetDialog(); if (ImGui::MenuItem("Add Shot")) m_projectContext.addShot(); if (ImGui::MenuItem("Render Active Shot...")) diff --git a/tsd/apps/interactive/scivisStudio/Application.h b/tsd/apps/interactive/scivisStudio/Application.h index 02c77c172..5cde6278e 100644 --- a/tsd/apps/interactive/scivisStudio/Application.h +++ b/tsd/apps/interactive/scivisStudio/Application.h @@ -23,7 +23,7 @@ struct Viewport; namespace tsd::scivis_studio { -struct AddDatasetDialog; +struct AddStaticDatasetDialog; struct CameraRigEditor; struct DatasetEditor; struct LightRigEditor; @@ -52,7 +52,7 @@ class Application : public tsd::ui::imgui::Application ProjectContext &projectContext(); const ProjectContext &projectContext() const; - void showAddDatasetDialog(); + void showAddStaticDatasetDialog(); void showProjectLocationDialogForOpen(); void showProjectLocationDialogForSaveAs(); void renderActiveShot(); @@ -106,7 +106,7 @@ class Application : public tsd::ui::imgui::Application tsd::ui::imgui::TransferFunctionEditor *m_transferFunctionEditor{nullptr}; std::unique_ptr m_projectLocationDialog; - std::unique_ptr m_addDatasetDialog; + std::unique_ptr m_addStaticDatasetDialog; ConfirmationModalState m_confirmationModal; }; diff --git a/tsd/apps/interactive/scivisStudio/CMakeLists.txt b/tsd/apps/interactive/scivisStudio/CMakeLists.txt index b998c6541..016b77ece 100644 --- a/tsd/apps/interactive/scivisStudio/CMakeLists.txt +++ b/tsd/apps/interactive/scivisStudio/CMakeLists.txt @@ -43,7 +43,7 @@ configure_file( add_executable(scivisStudio Application.cpp - modals/AddDatasetDialog.cpp + modals/AddStaticDatasetDialog.cpp modals/ProjectLocationDialog.cpp scivisStudio.cpp windows/CameraRigEditor.cpp diff --git a/tsd/apps/interactive/scivisStudio/default_ui_layout.txt b/tsd/apps/interactive/scivisStudio/default_ui_layout.txt index a8e450154..42f546c1a 100644 --- a/tsd/apps/interactive/scivisStudio/default_ui_layout.txt +++ b/tsd/apps/interactive/scivisStudio/default_ui_layout.txt @@ -71,7 +71,7 @@ Pos=1592,1023 Size=656,216 Collapsed=0 -[Window][Add Dataset] +[Window][Add Static Dataset] Pos=1540,890 Size=759,242 Collapsed=0 @@ -108,4 +108,3 @@ DockSpace ID=0x80F5B4C5 Window=0x079D3A04 Pos=0,56 Size=3840,2206 Split=X 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/AddDatasetDialog.cpp b/tsd/apps/interactive/scivisStudio/modals/AddStaticDatasetDialog.cpp similarity index 93% rename from tsd/apps/interactive/scivisStudio/modals/AddDatasetDialog.cpp rename to tsd/apps/interactive/scivisStudio/modals/AddStaticDatasetDialog.cpp index b6365b6f2..c8e3b6059 100644 --- a/tsd/apps/interactive/scivisStudio/modals/AddDatasetDialog.cpp +++ b/tsd/apps/interactive/scivisStudio/modals/AddStaticDatasetDialog.cpp @@ -1,7 +1,7 @@ // Copyright 2026 NVIDIA Corporation // SPDX-License-Identifier: Apache-2.0 -#include "AddDatasetDialog.h" +#include "AddStaticDatasetDialog.h" #include "tsd/core/Logging.hpp" #include "tsd/ui/imgui/Application.h" @@ -60,14 +60,14 @@ void copyToInputBuffer(std::array &buffer, const std::string &value) } // namespace -AddDatasetDialog::AddDatasetDialog( +AddStaticDatasetDialog::AddStaticDatasetDialog( tsd::ui::imgui::Application *app, ProjectContext *projectContext) - : Modal(app, "Add Dataset"), m_projectContext(projectContext) + : Modal(app, "Add Static Dataset"), m_projectContext(projectContext) {} -AddDatasetDialog::~AddDatasetDialog() = default; +AddStaticDatasetDialog::~AddStaticDatasetDialog() = default; -void AddDatasetDialog::buildUI() +void AddStaticDatasetDialog::buildUI() { ImGui::InputText("Name", m_name.data(), m_name.size()); diff --git a/tsd/apps/interactive/scivisStudio/modals/AddDatasetDialog.h b/tsd/apps/interactive/scivisStudio/modals/AddStaticDatasetDialog.h similarity index 81% rename from tsd/apps/interactive/scivisStudio/modals/AddDatasetDialog.h rename to tsd/apps/interactive/scivisStudio/modals/AddStaticDatasetDialog.h index 4e9943045..11dd8dcad 100644 --- a/tsd/apps/interactive/scivisStudio/modals/AddDatasetDialog.h +++ b/tsd/apps/interactive/scivisStudio/modals/AddStaticDatasetDialog.h @@ -11,11 +11,11 @@ namespace tsd::scivis_studio { -struct AddDatasetDialog : public tsd::ui::imgui::Modal +struct AddStaticDatasetDialog : public tsd::ui::imgui::Modal { - AddDatasetDialog( + AddStaticDatasetDialog( tsd::ui::imgui::Application *app, ProjectContext *projectContext); - ~AddDatasetDialog() override; + ~AddStaticDatasetDialog() override; private: void buildUI() override; From e3e7d62e86086b7d4a2c20e95bfac57fbe6f62ce Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Sun, 31 May 2026 19:05:26 -0600 Subject: [PATCH 47/49] add file animations --- .../interactive/scivisStudio/Application.cpp | 23 +- .../interactive/scivisStudio/Application.h | 3 + .../interactive/scivisStudio/CMakeLists.txt | 1 + tsd/apps/interactive/scivisStudio/Dataset.h | 10 + .../scivisStudio/ProjectContext.cpp | 175 ++++++++- .../interactive/scivisStudio/ProjectContext.h | 13 + .../scivisStudio/ProjectSerialization.cpp | 65 +++- .../modals/AddFileAnimationDatasetDialog.cpp | 362 ++++++++++++++++++ .../modals/AddFileAnimationDatasetDialog.h | 42 ++ .../scivisStudio/windows/DatasetEditor.cpp | 31 ++ tsd/src/tsd/ui/imgui/Application.cpp | 24 ++ tsd/src/tsd/ui/imgui/Application.h | 1 + tsd/tests/test_SciVisStudio.cpp | 35 ++ 13 files changed, 767 insertions(+), 18 deletions(-) create mode 100644 tsd/apps/interactive/scivisStudio/modals/AddFileAnimationDatasetDialog.cpp create mode 100644 tsd/apps/interactive/scivisStudio/modals/AddFileAnimationDatasetDialog.h diff --git a/tsd/apps/interactive/scivisStudio/Application.cpp b/tsd/apps/interactive/scivisStudio/Application.cpp index 198f0af5a..176634fe6 100644 --- a/tsd/apps/interactive/scivisStudio/Application.cpp +++ b/tsd/apps/interactive/scivisStudio/Application.cpp @@ -5,6 +5,7 @@ #include "DefaultLayout.h" #include "RenderShot.h" +#include "modals/AddFileAnimationDatasetDialog.h" #include "modals/AddStaticDatasetDialog.h" #include "modals/ProjectLocationDialog.h" #include "windows/CameraRigEditor.h" @@ -176,6 +177,8 @@ tsd::ui::imgui::WindowArray Application::setupWindows() 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)) { @@ -476,6 +479,11 @@ void Application::showAddStaticDatasetDialog() m_addStaticDatasetDialog->show(); } +void Application::showAddFileAnimationDatasetDialog() +{ + m_addFileAnimationDatasetDialog->show(); +} + void Application::showProjectLocationDialogForOpen() { m_projectLocationDialog->configure(ProjectLocationMode::OpenProject, @@ -587,6 +595,12 @@ void Application::uiFrameStart() 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(); @@ -628,8 +642,13 @@ void Application::uiMainMenuBar() } if (ImGui::BeginMenu("Studio")) { - if (ImGui::MenuItem("Add Static Dataset...")) - showAddStaticDatasetDialog(); + 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...")) diff --git a/tsd/apps/interactive/scivisStudio/Application.h b/tsd/apps/interactive/scivisStudio/Application.h index 5cde6278e..0c91eb715 100644 --- a/tsd/apps/interactive/scivisStudio/Application.h +++ b/tsd/apps/interactive/scivisStudio/Application.h @@ -24,6 +24,7 @@ struct Viewport; namespace tsd::scivis_studio { struct AddStaticDatasetDialog; +struct AddFileAnimationDatasetDialog; struct CameraRigEditor; struct DatasetEditor; struct LightRigEditor; @@ -53,6 +54,7 @@ class Application : public tsd::ui::imgui::Application const ProjectContext &projectContext() const; void showAddStaticDatasetDialog(); + void showAddFileAnimationDatasetDialog(); void showProjectLocationDialogForOpen(); void showProjectLocationDialogForSaveAs(); void renderActiveShot(); @@ -107,6 +109,7 @@ class Application : public tsd::ui::imgui::Application std::unique_ptr m_projectLocationDialog; std::unique_ptr m_addStaticDatasetDialog; + std::unique_ptr m_addFileAnimationDatasetDialog; ConfirmationModalState m_confirmationModal; }; diff --git a/tsd/apps/interactive/scivisStudio/CMakeLists.txt b/tsd/apps/interactive/scivisStudio/CMakeLists.txt index 016b77ece..e103346d8 100644 --- a/tsd/apps/interactive/scivisStudio/CMakeLists.txt +++ b/tsd/apps/interactive/scivisStudio/CMakeLists.txt @@ -43,6 +43,7 @@ configure_file( add_executable(scivisStudio Application.cpp + modals/AddFileAnimationDatasetDialog.cpp modals/AddStaticDatasetDialog.cpp modals/ProjectLocationDialog.cpp scivisStudio.cpp diff --git a/tsd/apps/interactive/scivisStudio/Dataset.h b/tsd/apps/interactive/scivisStudio/Dataset.h index eeceacf1a..df50db79b 100644 --- a/tsd/apps/interactive/scivisStudio/Dataset.h +++ b/tsd/apps/interactive/scivisStudio/Dataset.h @@ -9,6 +9,7 @@ #include #include +#include namespace tsd::scivis_studio { @@ -52,6 +53,14 @@ struct DatasetSourceMetadata int64_t modifiedTime{0}; }; +struct DatasetSourceFile +{ + std::string absolutePath; + std::string projectRelativePath; + uint64_t fileSize{0}; + int64_t modifiedTime{0}; +}; + struct Dataset { DatasetID id; @@ -61,6 +70,7 @@ struct Dataset DatasetSourceMetadata source; DatasetStatus status{DatasetStatus::Missing}; SceneNodeRef rootNode; + std::vector sourceFiles; }; namespace dataset { diff --git a/tsd/apps/interactive/scivisStudio/ProjectContext.cpp b/tsd/apps/interactive/scivisStudio/ProjectContext.cpp index 7d56af53e..ce01d3afc 100644 --- a/tsd/apps/interactive/scivisStudio/ProjectContext.cpp +++ b/tsd/apps/interactive/scivisStudio/ProjectContext.cpp @@ -15,6 +15,7 @@ #include #include #include +#include namespace tsd::scivis_studio { @@ -35,6 +36,15 @@ static tsd::scene::LayerNodeRef findDirectChild( 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(); @@ -408,7 +418,10 @@ static DatasetSourceMetadata collectSourceMetadata( DatasetSourceMetadata metadata; std::error_code ec; auto absolute = std::filesystem::absolute(sourcePath, ec); - metadata.absolutePath = ec ? sourcePath.string() : absolute.string(); + if (ec) + absolute = sourcePath; + absolute = absolute.lexically_normal(); + metadata.absolutePath = absolute.string(); if (!projectDirectory.empty()) { auto relative = std::filesystem::relative(absolute, projectDirectory, ec); @@ -428,6 +441,15 @@ static DatasetSourceMetadata collectSourceMetadata( 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) @@ -476,6 +498,108 @@ Dataset *ProjectContext::addStaticDataset(const std::string &name, 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) @@ -717,15 +841,60 @@ bool ProjectContext::openProject(const std::filesystem::path &directory, 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; - if (!dataset.source.absolutePath.empty() - && !std::filesystem::exists(dataset.source.absolutePath)) + 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) diff --git a/tsd/apps/interactive/scivisStudio/ProjectContext.h b/tsd/apps/interactive/scivisStudio/ProjectContext.h index 82a769915..60adcf3cf 100644 --- a/tsd/apps/interactive/scivisStudio/ProjectContext.h +++ b/tsd/apps/interactive/scivisStudio/ProjectContext.h @@ -10,9 +10,15 @@ #include #include +#include namespace tsd::scivis_studio { +struct FileAnimationDatasetOptions +{ + bool setActiveShotFrameCount{true}; +}; + struct ProjectContext { ProjectContext() = default; @@ -29,6 +35,10 @@ struct ProjectContext 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(); @@ -50,6 +60,9 @@ struct ProjectContext 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( diff --git a/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp b/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp index d2def179c..0fdd1de96 100644 --- a/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp +++ b/tsd/apps/interactive/scivisStudio/ProjectSerialization.cpp @@ -58,6 +58,44 @@ static void nodeToCameraRig(tsd::core::DataNode &node, ShotCameraRig &rig) 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(); @@ -75,11 +113,11 @@ void projectToNode(const Project &project, tsd::core::DataNode &node) d["importerType"] = dataset.importerType; d["status"] = dataset::toString(dataset.status); - auto &source = d["source"]; - source["absolutePath"] = dataset.source.absolutePath; - source["projectRelativePath"] = dataset.source.projectRelativePath; - source["fileSize"] = dataset.source.fileSize; - source["modifiedTime"] = dataset.source.modifiedTime; + sourceMetadataToNode(dataset.source, d["source"]); + + auto &sourceFiles = d["sourceFiles"]; + for (const auto &sourceFile : dataset.sourceFiles) + sourceFileToNode(sourceFile, sourceFiles.append()); } auto &shots = node["shots"]; @@ -147,14 +185,15 @@ bool nodeToProject(tsd::core::DataNode &node, Project &project) dataset.status = dataset::statusFromString( d["status"].getValueOr("Missing")); - if (auto *source = d.child("source")) { - dataset.source.absolutePath = - (*source)["absolutePath"].getValueOr(""); - dataset.source.projectRelativePath = - (*source)["projectRelativePath"].getValueOr(""); - dataset.source.fileSize = (*source)["fileSize"].getValueOr(0); - dataset.source.modifiedTime = - (*source)["modifiedTime"].getValueOr(0); + 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)); }); 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/windows/DatasetEditor.cpp b/tsd/apps/interactive/scivisStudio/windows/DatasetEditor.cpp index 594d8b6fb..62f93ebb4 100644 --- a/tsd/apps/interactive/scivisStudio/windows/DatasetEditor.cpp +++ b/tsd/apps/interactive/scivisStudio/windows/DatasetEditor.cpp @@ -5,6 +5,8 @@ #include "imgui.h" +#include + namespace tsd::scivis_studio { DatasetEditor::DatasetEditor( @@ -46,6 +48,35 @@ void DatasetEditor::buildUI() 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); diff --git a/tsd/src/tsd/ui/imgui/Application.cpp b/tsd/src/tsd/ui/imgui/Application.cpp index 600822b8c..f1ddfdeda 100644 --- a/tsd/src/tsd/ui/imgui/Application.cpp +++ b/tsd/src/tsd/ui/imgui/Application.cpp @@ -138,6 +138,30 @@ void Application::getFilenameFromDialog( } } +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(); diff --git a/tsd/src/tsd/ui/imgui/Application.h b/tsd/src/tsd/ui/imgui/Application.h index b81ae2d11..272a2b037 100644 --- a/tsd/src/tsd/ui/imgui/Application.h +++ b/tsd/src/tsd/ui/imgui/Application.h @@ -72,6 +72,7 @@ class Application 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 diff --git a/tsd/tests/test_SciVisStudio.cpp b/tsd/tests/test_SciVisStudio.cpp index fdf326077..049d2ed3f 100644 --- a/tsd/tests/test_SciVisStudio.cpp +++ b/tsd/tests/test_SciVisStudio.cpp @@ -15,6 +15,7 @@ #include "tsd/scene/objects/Light.hpp" #include +#include #include using namespace tsd::scivis_studio; @@ -62,6 +63,10 @@ SCENARIO("SciVis Studio project model serialization", "[SciVisStudio]") {"/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"; @@ -88,6 +93,7 @@ SCENARIO("SciVis Studio project model serialization", "[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); @@ -99,6 +105,9 @@ SCENARIO("SciVis Studio project model serialization", "[SciVisStudio]") { 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"); @@ -118,6 +127,32 @@ SCENARIO("SciVis Studio project model serialization", "[SciVisStudio]") } } +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") From 5bca1cd3bd3970eec80d8542c974256615114e28 Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Tue, 2 Jun 2026 19:32:09 -0500 Subject: [PATCH 48/49] implement import/export of individual surface + volume objects --- tsd/src/tsd/io/CMakeLists.txt | 1 + tsd/src/tsd/io/serialization.hpp | 9 + .../io/serialization/serialization_object.cpp | 1208 +++++++++++++++++ tsd/src/tsd/ui/imgui/Application.cpp | 19 + tsd/src/tsd/ui/imgui/Application.h | 7 + tsd/src/tsd/ui/imgui/CMakeLists.txt | 1 + .../tsd/ui/imgui/modals/ObjectFileDialog.cpp | 191 +++ .../tsd/ui/imgui/modals/ObjectFileDialog.h | 56 + tsd/src/tsd/ui/imgui/windows/LayerTree.cpp | 35 +- tsd/tests/test_Serialization.cpp | 409 ++++++ 10 files changed, 1934 insertions(+), 2 deletions(-) create mode 100644 tsd/src/tsd/io/serialization/serialization_object.cpp create mode 100644 tsd/src/tsd/ui/imgui/modals/ObjectFileDialog.cpp create mode 100644 tsd/src/tsd/ui/imgui/modals/ObjectFileDialog.h 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/serialization.hpp b/tsd/src/tsd/io/serialization.hpp index bc69ceed1..e3cdc2f54 100644 --- a/tsd/src/tsd/io/serialization.hpp +++ b/tsd/src/tsd/io/serialization.hpp @@ -38,6 +38,8 @@ 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 @@ -117,6 +119,13 @@ 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_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/ui/imgui/Application.cpp b/tsd/src/tsd/ui/imgui/Application.cpp index f1ddfdeda..6bffa32d4 100644 --- a/tsd/src/tsd/ui/imgui/Application.cpp +++ b/tsd/src/tsd/ui/imgui/Application.cpp @@ -172,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(); @@ -373,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); @@ -459,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; diff --git a/tsd/src/tsd/ui/imgui/Application.h b/tsd/src/tsd/ui/imgui/Application.h index 272a2b037..e7e756d37 100644 --- a/tsd/src/tsd/ui/imgui/Application.h +++ b/tsd/src/tsd/ui/imgui/Application.h @@ -10,6 +10,7 @@ #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 @@ -87,6 +88,11 @@ class Application 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; @@ -162,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; 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/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/windows/LayerTree.cpp b/tsd/src/tsd/ui/imgui/windows/LayerTree.cpp index e8077ff49..5f1e8ee77 100644 --- a/tsd/src/tsd/ui/imgui/windows/LayerTree.cpp +++ b/tsd/src/tsd/ui/imgui/windows/LayerTree.cpp @@ -684,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); @@ -773,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/tests/test_Serialization.cpp b/tsd/tests/test_Serialization.cpp index a1d30a9e8..e84391e53 100644 --- a/tsd/tests/test_Serialization.cpp +++ b/tsd/tests/test_Serialization.cpp @@ -8,6 +8,33 @@ #include "tsd/core/DataTree.hpp" #include "tsd/io/serialization.hpp" #include "tsd/scene/Scene.hpp" +// std +#include +#include +#include + +namespace { + +std::string testFile(const char *name) +{ + return std::string("/tmp/") + name; +} + +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]") { @@ -233,3 +260,385 @@ SCENARIO("tsd::io scene payload metadata validation", "[Serialization]") } } } + +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)); + } + } +} From 8dda79cb255146d52b4be3a780b116580749c9bb Mon Sep 17 00:00:00 2001 From: Jefferson Amstutz Date: Wed, 3 Jun 2026 11:43:46 -0500 Subject: [PATCH 49/49] fix bad unit test path on Windows --- tsd/tests/test_Serialization.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tsd/tests/test_Serialization.cpp b/tsd/tests/test_Serialization.cpp index e84391e53..aec0fe3c5 100644 --- a/tsd/tests/test_Serialization.cpp +++ b/tsd/tests/test_Serialization.cpp @@ -10,6 +10,7 @@ #include "tsd/scene/Scene.hpp" // std #include +#include #include #include @@ -17,7 +18,7 @@ namespace { std::string testFile(const char *name) { - return std::string("/tmp/") + name; + return (std::filesystem::temp_directory_path() / name).string(); } void removeTestFile(const std::string &filename)